diff --git a/.github/workflows/markdownlint-tests.yml b/.github/workflows/markdownlint-tests.yml index c59a32b..f0323fe 100644 --- a/.github/workflows/markdownlint-tests.yml +++ b/.github/workflows/markdownlint-tests.yml @@ -1,7 +1,8 @@ name: Markdownlint tests # Positive tests must pass markdownlint; negative tests must fail. -# Fails if positive fails lint or if any negative passes lint. +# Also runs markdownlint --fix functional tests (test_fix_*.py). +# Keep in sync with Makefile targets test-markdownlint and test-markdownlint-fix. on: push: @@ -10,12 +11,16 @@ on: - '.markdownlint.yml' - 'markdownlint-rules/**' - 'md_test_files/**' + - 'test-scripts/test_fix_*.py' + - 'test-scripts/markdownlint_config_helper.py' pull_request: paths: - '.markdownlint-cli2.jsonc' - '.markdownlint.yml' - 'markdownlint-rules/**' - 'md_test_files/**' + - 'test-scripts/test_fix_*.py' + - 'test-scripts/markdownlint_config_helper.py' jobs: markdownlint-tests: @@ -43,3 +48,6 @@ jobs: - name: Run markdownlint fixture tests run: make test-markdownlint + + - name: Run markdownlint --fix functional tests + run: make test-markdownlint-fix diff --git a/.gitignore b/.gitignore index 2daf852..3add95e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ # Directories -.vscode/ .cursor/ .venv/ -node_modules/ +.vscode/ Abacus.ai*/ +dev_docs/ +node_modules/ tmp/ # File Patterns diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index c3adc82..b35076c 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -14,7 +14,8 @@ "markdownlint-rules/no-duplicate-headings-normalized.js", "markdownlint-rules/no-empty-heading.js", "markdownlint-rules/no-h1-content.js", - "markdownlint-rules/no-heading-like-lines.js" + "markdownlint-rules/no-heading-like-lines.js", + "markdownlint-rules/one-sentence-per-line.js" ], "ignores": [ ".github/**", diff --git a/.markdownlint.yml b/.markdownlint.yml index 5306fe4..c6caf55 100644 --- a/.markdownlint.yml +++ b/.markdownlint.yml @@ -3,7 +3,16 @@ default: true -# MD013/line-length - line length limits (non-default) +# MD013/line-length - line length limits (built-in; default off when overridden). +# Options: +# line_length: number (default 80); max characters per line for body +# heading_line_length: number (default 80); max for heading lines +# code_block_line_length: number (default 80); max for code block lines +# code_blocks: bool (default true); include code blocks in checks +# tables: bool (default true); include tables in checks +# headings: bool (default true); include headings in checks +# strict: bool (default false); enforce length even when no whitespace beyond limit +# stern: bool (default false); warn for fixable long lines but allow long lines without spaces MD013: line_length: 500 heading_line_length: 500 @@ -14,21 +23,23 @@ MD013: strict: false stern: false -# MD033/no-inline-html - allow anchor elements for custom anchors +# MD033/no-inline-html - allow only listed HTML elements (built-in). +# Options: +# allowed_elements: list of element names (e.g. "a", "br"); only these HTML elements are allowed MD033: allowed_elements: ["a"] -# ascii-only: disallow non-ASCII except in configured paths; inline code (backticks) always stripped -# No built-in path/emoji defaults. Options: -# - allowedPathPatternsUnicode: glob list; files where any non-ASCII is allowed -# - allowedPathPatternsEmoji: glob list; files where only allowedEmoji chars are allowed -# - allowedEmoji: emoji/chars allowed in paths matching allowedPathPatternsEmoji (multi-codepoint OK) -# - allowedUnicode: chars allowed in all files; extends default set (é, ï, ñ, ç, etc.) unless replace below -# - allowedUnicodeReplaceDefault: true = use only allowedUnicode list (no default set) -# - allowUnicodeInCodeBlocks: true (default) = skip fenced blocks; false = check them for unicode -# - disallowUnicodeInCodeBlockTypes: when allowUnicodeInCodeBlocks false, only these block types checked -# (block type = first word after opening fence, e.g. "text" from ```text); empty = check all blocks -# - unicodeReplacements: object or [char, replacement] array; built-in defaults (arrows, quotes, <=>=*) if omitted +# ascii-only: disallow non-ASCII except in configured paths; inline code (backticks) always stripped. Fixable when replacement in unicodeReplacements. +# Options (all optional): +# allowedEmoji: list of chars/emoji allowed in paths matching allowedPathPatternsEmoji (multi-codepoint OK) +# allowedPathPatternsEmoji: list of globs; files where only allowedEmoji chars are allowed +# allowedPathPatternsUnicode: list of globs; files where any non-ASCII is allowed +# allowedUnicode: list of single-char strings; allowed in all files (extends default é, ï, ñ, etc. unless replace below) +# allowedUnicodeReplaceDefault: bool (default false); true = use only allowedUnicode list, no built-in default +# allowUnicodeInCodeBlocks: bool (default true); false = check inside fenced blocks +# disallowUnicodeInCodeBlockTypes: list of block types (e.g. "text", "bash"); when allowUnicodeInCodeBlocks false, only these checked; empty = all +# excludePathPatterns: list of globs; skip this rule for matching file paths +# unicodeReplacements: object or [[char, replacement], ...]; built-in defaults (arrows, quotes, etc.) if omitted ascii-only: allowedPathPatternsUnicode: # - "**/README.md" @@ -43,31 +54,54 @@ ascii-only: - "📊" - "⚠️" -# document-length: disallow documents longer than maximum lines (default 1500) -# - maximum: positive integer; default 1500 +# document-length: disallow documents longer than maximum lines. +# Options: +# maximum: number (default 1500); max allowed line count; must be positive integer +# excludePathPatterns: list of globs; skip this rule for matching file paths document-length: maximum: 1500 -# heading-title-case: AP-style headline capitalization; words in backticks ignored +# heading-title-case: AP-style headline capitalization; words in backticks and file names (suggest backticks) handled. Fixable. +# Options (all optional): +# lowercaseWords: list of strings; words that must be lowercase in middle (extends default list) +# lowercaseWordsReplaceDefault: bool (default false); true = use only lowercaseWords list, no built-in default +# excludePathPatterns: list of globs; skip this rule for matching file paths # heading-title-case: -# lowercaseWords: ["through", ...] # optional; extends default list (add words) -# lowercaseWordsReplaceDefault: true # optional; true = use only lowercaseWords list, no default set +# lowercaseWords: ["through"] +# lowercaseWordsReplaceDefault: true -# no-h1-content: under first h1 allow only TOC (blank, list-of-links, HTML comments) -# - excludePathPatterns: glob list; skip this rule for matching paths (e.g. md_test_files for fixtures) +# no-heading-like-lines: report lines that look like headings but are not (e.g. **Text:**, 1. **Text**, MD036-style **Intro**). Fixable. +# Options (all optional): +# convertToHeading: bool (default false); when true, fix converts to ATX heading; when false, fix strips emphasis to plain text +# defaultHeadingLevel: number 1-6 (default 2); used when converting to heading and there is no preceding heading +# fixedHeadingLevel: number 1-6; when set, suggested heading uses this level and ignores context +# punctuationMarks: string (default ".,;!?"); for whole-line emphasis, skip when content ends with one of these (no colon = catch colon lines) +# excludePathPatterns: list of globs; skip this rule for matching file paths +# With convertToHeading true, heading-title-case.js and heading-numbering.js in same dir (if present) add AP title case and numbering. +# no-heading-like-lines: +# convertToHeading: false +# defaultHeadingLevel: 2 +# # fixedHeadingLevel: 3 +# # excludePathPatterns: ["**/README.md"] + +# no-h1-content: under first h1 allow only TOC (blank lines, list-of-links, badges, HTML comments including multi-line). +# Options: +# excludePathPatterns: list of globs; skip this rule for matching file paths # no-h1-content: # excludePathPatterns: # - "README.md" # - "CONTRIBUTING.md" # - "**/README.md" -# fenced-code-under-heading: code blocks for given languages must sit under an H2-H6 heading; use when grouping code under section titles -# - languages: list of info strings (e.g. go, bash); only these block types are checked (first word after opening fence) -# - minHeadingLevel / maxHeadingLevel: numbers (default 2 and 6); heading levels that count; blocks must fall under a heading in this range -# - maxBlocksPerHeading: number (optional); max blocks that match languages per heading; only configured language(s) count (other block types allowed) -# - requireHeading: bool (default true); when true, every applicable block must have a preceding heading -# - exclusive: bool (default false); when true, at most one fenced code block (any language) per heading, and it must be one of languages -# - excludePaths / includePaths: glob lists; skip or restrict which paths are checked (includePaths set = only those files) +# fenced-code-under-heading: code blocks for given languages must sit under an H2-H6 heading. +# Options: +# languages: list of strings (required); info strings (e.g. go, bash) to check; block type = first word after opening fence +# minHeadingLevel / maxHeadingLevel: numbers (default 2 and 6); heading levels that count +# maxBlocksPerHeading: number; max blocks matching languages per heading; only configured language(s) count unless exclusive +# requireHeading: bool (default true); when true, every applicable block must have a preceding heading +# exclusive: bool (default false); when true, at most one fenced block (any language) per heading, and it must be one of languages +# excludePaths / includePaths: list of globs; excludePaths skips matching paths; includePaths (when set) = only those files checked +# excludePathPatterns: list of globs; skip this rule for matching file paths (alias for excludePaths in this rule) fenced-code-under-heading: languages: - "go" @@ -76,6 +110,7 @@ fenced-code-under-heading: - "yaml" - "bash" - "json" + - "js" maxBlocksPerHeading: 1 requireHeading: true exclusive: true # one block total per heading; must be one of languages @@ -86,32 +121,39 @@ fenced-code-under-heading: - "md_test_files/README.md" - "test-scripts/README.md" -# heading-min-words: headings at or below a level must have at least N words; use to avoid single-word or empty-looking headings -# - minWords: number (required); minimum word count per heading (after optional numbering strip) -# - applyToLevelsAtOrBelow: number (required); apply to headings at this level or deeper (e.g. 4 = H1-H4 checked) -# - minLevel / maxLevel: numbers (optional); only headings in this level range checked (default 1-6) -# - excludePaths / includePaths: glob lists; skip or restrict which paths are checked -# - allowList: list of exact heading titles (after strip) allowed even if below minWords (e.g. "Overview", "Summary") -# - stripNumbering: bool (default true); when true, leading numbering (e.g. 1.2.3) stripped before counting words and allowList match +# heading-min-words: headings at or below a level must have at least N words (after optional numbering strip). +# Options: +# minWords: number (required); minimum word count per heading +# applyToLevelsAtOrBelow: number (required); apply to headings at this level or deeper (e.g. 4 = H1-H4) +# minLevel / maxLevel: numbers (optional); only headings in this level range checked (default 1-6) +# excludePaths / includePaths: list of globs; excludePaths skips; includePaths (when set) = only those files +# allowList: list of exact heading titles (after strip) allowed even if below minWords (e.g. "Overview", "Summary") +# stripNumbering: bool (default true); when true, leading numbering (e.g. 1.2.3) stripped before counting and allowList match +# excludePathPatterns: list of globs; skip this rule for matching file paths heading-min-words: minWords: 2 applyToLevelsAtOrBelow: 4 -# no-empty-heading: H2+ must have content; allow file-level override or per-section suppress comment -# - minimumContentLines: number (default 1); minimum lines that count as content under each H2+ -# - countBlankLinesAsContent: bool (default false); count blank lines toward minimum -# - countHTMLCommentsAsContent: bool (default false); count HTML-comment-only lines -# - countHtmlLinesAsContent: bool (default false); count HTML-tag-only lines (e.g.
) -# - countCodeBlockLinesAsContent: bool (default true); count lines inside ```/~~~ blocks -# - excludePathPatterns: glob list; skip this rule for matching paths (e.g. **/*_index.md) -# - Only "" on its own line suppresses the error. +# no-empty-heading: H2+ must have content before any subheading; single- and multi-line HTML comments supported. +# Options (all optional): +# minimumContentLines: number (default 1); minimum lines that count as content under each H2+ +# countBlankLinesAsContent: bool (default false); count blank lines toward minimum +# countHTMLCommentsAsContent: bool (default false); count HTML-comment-only lines (including multi-line ) +# countHtmlLinesAsContent: bool (default false); count HTML-tag-only lines (e.g.
) +# countCodeBlockLinesAsContent: bool (default true); count lines inside ```/~~~ blocks +# excludePathPatterns: list of globs; skip this rule for matching file paths +# Suppress per section: "" on its own line allows that section to be empty. no-empty-heading: excludePathPatterns: - "**/*_index.md" -# allow-custom-anchors: require anchor ids to match patterns; optional placement (line/heading) per pattern -# - allowedIdPatterns: list of regex strings or { pattern, placement? }; placement applies per pattern -# - strictPlacement: true (default) = enforce placement when a pattern has placement set +# allow-custom-anchors: allow only configured anchor id patterns; optional placement per pattern. +# Options: +# allowedIdPatterns: array (required); each entry regex string or { pattern: string, placement?: object } +# placement: headingMatch (regex), lineMatch (regex), standaloneLine (bool), requireAfter (["blank","fencedBlock","list"]), +# anchorImmediatelyAfterHeading (bool), maxPerSection (number) +# strictPlacement: bool (default true); when true, enforce placement when the matching pattern has placement set +# excludePathPatterns: list of globs; skip this rule for matching file paths allow-custom-anchors: allowedIdPatterns: - pattern: "^spec-[a-z0-9-]+$" @@ -138,4 +180,33 @@ allow-custom-anchors: anchorImmediatelyAfterHeading: true maxPerSection: 1 strictPlacement: true + +# heading-numbering: enforce numeric outline (e.g. 1, 1.1, 1.2). Fixable. Not configured here; options documented for reference. +# Options (all optional): +# maxHeadingLevel: number 1-6 (default 6); headings at or below this level must be numbered +# maxSegmentValue: number (default 99); max value for any segment (e.g. 99 => 1.99.3 allowed) +# maxSegmentValueMinLevel: number 1-6 (default 2); min heading level for maxSegmentValue cap +# maxSegmentValueMaxLevel: number 1-6 (default 6); max heading level for maxSegmentValue cap +# excludePathPatterns: list of globs; skip this rule for matching file paths +# heading-numbering: +# maxHeadingLevel: 6 +# maxSegmentValue: 99 +# maxSegmentValueMinLevel: 2 +# maxSegmentValueMaxLevel: 6 +# # excludePathPatterns: ["**/README.md"] + +# no-duplicate-headings-normalized: disallow duplicate heading text after normalizing (whitespace/case). Not configured here. +# Options: +# excludePathPatterns: list of globs; skip this rule for matching file paths +# no-duplicate-headings-normalized: +# # excludePathPatterns: ["**/README.md"] + +# one-sentence-per-line: enforce one sentence per line in prose and list content. Fixable (repeated Fix all). +# Options (all optional): +# continuationIndent: number (default 4); spaces for paragraph continuation lines +# strictAbbreviations: list of strings; abbreviations that do not end a sentence (extends/replaces default) +# excludePathPatterns: list of globs; skip this rule for matching file paths +# one-sentence-per-line: +# continuationIndent: 4 +# # excludePathPatterns: ["**/README.md"] ... diff --git a/.vscode/settings.json b/.vscode/settings.json index 805bcf0..eea9984 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "./markdownlint-rules/no-duplicate-headings-normalized.js", "./markdownlint-rules/no-empty-heading.js", "./markdownlint-rules/no-h1-content.js", - "./markdownlint-rules/no-heading-like-lines.js" + "./markdownlint-rules/no-heading-like-lines.js", + "./markdownlint-rules/one-sentence-per-line.js" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad71b80..d9691b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,10 @@ npm install Run the Makefile targets below so your PR stays green. -## Checks - Makefile Targets +## Checks - `Makefile` Targets -These targets mirror the GitHub Actions workflows. Run them locally before pushing. +These targets mirror the GitHub Actions workflows. +Run them locally before pushing. ### Lint Rule JavaScript (`make lint-js`) @@ -57,7 +58,8 @@ These targets mirror the GitHub Actions workflows. Run them locally before pushi make test-markdownlint ``` - When adding or changing a custom rule, add or update a `negative_*.md` fixture so the intended violation is covered. See [md_test_files/README.md](md_test_files/README.md) for which file exercises which rule. + When adding or changing a custom rule, add or update a `negative_*.md` fixture so the intended violation is covered. + See [md_test_files/README.md](md_test_files/README.md) for which file exercises which rule. ### Rule Unit Tests (`make test-rules`) @@ -109,8 +111,10 @@ Or run individual targets: `make lint-js && make test-rules && make test-markdow ## Custom Rules -- Rule code lives in [markdownlint-rules/](markdownlint-rules/). Do not register `utils.js` as a rule; it is a shared helper. -- Config for custom rules is in [.markdownlint.yml](.markdownlint.yml). Rule docs and reuse instructions are in [markdownlint-rules/README.md](markdownlint-rules/README.md). +- Rule code lives in [markdownlint-rules/](markdownlint-rules/). + Do not register `utils.js` as a rule; it is a shared helper. +- Config for custom rules is in [.markdownlint.yml](.markdownlint.yml). + Rule docs and reuse instructions are in [markdownlint-rules/README.md](markdownlint-rules/README.md). ## Sync Notes diff --git a/Makefile b/Makefile index 8ed6fd2..7b9123d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -.PHONY: ci lint-js lint-readmes lint-python test-markdownlint test-python test-python-coverage test-rules test-rules-coverage venv +.PHONY: ci lint-js lint-readmes lint-python test-markdownlint test-markdownlint-fix test-markdownlint-options 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-coverage test-markdownlint lint-python test-python-coverage test-python lint-readmes +# Python: unit tests (test-python) vs functional tests (test-markdownlint-options, test-markdownlint-fix) are separate. +ci: lint-js test-rules-coverage lint-python test-python-coverage test-python test-markdownlint test-markdownlint-options test-markdownlint-fix 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. @@ -20,7 +21,7 @@ lint-readmes: MDL="npx markdownlint-cli2"; \ fi; \ echo "Linting READMEs..."; \ - $$MDL README.md **/README.md markdownlint-rules/README.md CONTRIBUTING.md + $$MDL --fix README.md **/README.md markdownlint-rules/README.md CONTRIBUTING.md # JavaScript linting - performs same checks as GitHub Actions workflow # NOTE: This target must be kept in sync with .github/workflows/js-lint.yml. @@ -149,14 +150,41 @@ test-markdownlint: } @python3 test-scripts/verify_markdownlint_fixtures.py $(if $(filter 1,$(VERBOSE)),--verbose) -# Python unit tests - same as .github/workflows/python-tests.yml +# Markdownlint rule-options functional tests - Python tests that run markdownlint with temp configs to exercise rule options. +# Requires: Node.js, npm (for markdownlint-cli2); Python 3. +test-markdownlint-options: + @command -v node >/dev/null 2>&1 || { \ + echo "Error: node not found. Install Node.js and run npm install."; \ + exit 1; \ + } + @command -v python3 >/dev/null 2>&1 || { \ + echo "Error: python3 not found. Install Python 3 to run tests."; \ + exit 1; \ + } + @PYTHONPATH="$(CURDIR)/test-scripts:$${PYTHONPATH:-}" python3 -m unittest discover -s test-scripts -p "test_markdownlint_options.py" -v + +# Markdownlint --fix functional tests - Python tests that run markdownlint-cli2 --fix and assert file content. +# Requires: Node.js, npm (for markdownlint-cli2); Python 3. +test-markdownlint-fix: + @command -v node >/dev/null 2>&1 || { \ + echo "Error: node not found. Install Node.js and run npm install."; \ + exit 1; \ + } + @command -v python3 >/dev/null 2>&1 || { \ + echo "Error: python3 not found. Install Python 3 to run tests."; \ + exit 1; \ + } + @PYTHONPATH="$(CURDIR)/test-scripts:$${PYTHONPATH:-}" python3 -m unittest discover -s test-scripts -p "test_fix_*.py" -v + +# Python unit tests - test the Python code itself (verifier parsing, expectations, etc.). Same as .github/workflows/python-tests.yml # NOTE: Keep in sync with that workflow. Requires: Python 3. +# Only test_verify_*.py (unit). Functional tests that exercise markdownlint rules: test-markdownlint-options, test-markdownlint-fix. test-python: @command -v python3 >/dev/null 2>&1 || { \ echo "Error: python3 not found. Install Python 3 to run tests."; \ exit 1; \ } - @python3 -m unittest discover -s test-scripts -p "test_*.py" -v + @PYTHONPATH="$(CURDIR)/test-scripts:$${PYTHONPATH:-}" python3 -m unittest discover -s test-scripts -p "test_verify_*.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). @@ -170,7 +198,7 @@ test-python-coverage: 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" && \ + PYTHONPATH="$(CURDIR)/test-scripts:$${PYTHONPATH:-}" 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 diff --git a/README.md b/README.md index 4004ca3..442d44b 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAnson/markdownlint) rules (JavaScript). -- **Custom markdownlint rules** in [markdownlint-rules/](markdownlint-rules/README.md) (intended to be **copied directly** into whatever repo wishes to use them; no need to depend on this repo): +- **Custom markdownlint rules** in [markdownlint-rules/](markdownlint-rules/README.md) (intended to be **copied directly** into whatever repo wishes to use them; no need to depend on this repo). + Some rules support auto-fix. - [allow-custom-anchors.js](markdownlint-rules/allow-custom-anchors.js) - Custom anchor validation. - Only allow `` whose ids match configured regex patterns; optional placement (heading match, line match, require-after, max per section). - Use when: enforcing stable fragment links (e.g. spec/algo docs) and consistent anchor placement. - [ascii-only.js](markdownlint-rules/ascii-only.js) - ASCII-only with path/emoji allowlists. - Disallow non-ASCII except in paths matching globs; allow Unicode or emoji-only in specific paths; optional replacement suggestions in errors. + Fixable when a replacement is configured (default map includes arrows, quotes, em dash). - Use when: keeping most docs ASCII while allowing Unicode/emoji only in chosen files (e.g. i18n or release notes). - [fenced-code-under-heading.js](markdownlint-rules/fenced-code-under-heading.js) - fenced code under heading. - For specified languages (e.g. `go`), every fenced block must sit under an H2-H6 heading; optional max blocks per heading and path filters. @@ -36,9 +38,11 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns - [heading-numbering.js](markdownlint-rules/heading-numbering.js) - heading numbering. - Enforce segment count by numbering root, sequential numbering per section, consistent period style (e.g. `1. Title` vs `1 Title`), optional `maxSegmentValue` and `maxHeadingLevel`. Default is 1-based (1., 2., 3.); if the first numbered heading in a section starts at 0 (e.g. `0.`, `0.0.`), that section is treated as 0-based and no error is reported. + Fixable for wrong sequence, missing prefix, wrong segment count, period style. - Use when: docs use numbered headings (e.g. `### 1.2.3 Title` or 0-based `### 0. Introduction`) and you want structure and style consistent. - [heading-title-case.js](markdownlint-rules/heading-title-case.js) - heading title case. - Enforce title case for headings; words in backticks ignored; configurable lowercase words (e.g. vs, and, the). + Fixable: corrects each violating word to AP title case. - Use when: you want consistent capitalization of headings (first/last and major words capped; small words lowercase in the middle). - [no-duplicate-headings-normalized.js](markdownlint-rules/no-duplicate-headings-normalized.js) - duplicate-heading checks. - Disallow duplicate heading titles after stripping numeric prefixes and normalizing case/whitespace; first occurrence is reference. @@ -49,13 +53,19 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns Only `` on its own line suppresses. - Use when: avoiding placeholder sections with no body content. - [no-heading-like-lines.js](markdownlint-rules/no-heading-like-lines.js) - no heading-like lines. - - Report lines that look like headings but are not (e.g. `**Text:**`, `1. **Text**`); prompt use of real `#` headings. + - Report lines that look like headings but are not (e.g. `**Text:**`, `1. **Text**`, whole-line emphasis `**Introduction**` / `*Note*`); default omits colon from sentence punctuation so colon lines are caught (greedier than MD036). + Fixable: default strips emphasis; optional `convertToHeading` converts to ATX heading (context-aware level; optional AP title case and numbering when heading-title-case and heading-numbering are present). - Use when: ensuring real Markdown headings instead of bold/italic that look like headings. - [no-h1-content.js](markdownlint-rules/no-h1-content.js) - no content under h1 except TOC. - Under the first h1, allow only table-of-contents content (blank lines, list-of-links, HTML comments). - Use when: enforcing that the only content under the doc title is a TOC. + - [one-sentence-per-line.js](markdownlint-rules/one-sentence-per-line.js) - one sentence per line. + - Enforce one sentence per line in prose and list content; skips decimals, abbreviations, inline code, filenames (period with no space after). + Fixable: splits all sentences on the line in one pass with configurable continuation indent. + - Use when: keeping prose and list items to one sentence per line for readability and diffs. - [document-length.js](markdownlint-rules/document-length.js) - maximum document length. - Disallow documents longer than a configured number of lines (default 1500); reports on line 1 when over the limit. + Optional `excludePathPatterns`. - Use when: keeping individual docs under a line cap to encourage splitting. - [utils.js](markdownlint-rules/utils.js) - shared utilities - Heading/content helpers and path/glob matching; used by other rules. @@ -63,17 +73,18 @@ Lint and docs-as-code tooling: custom [markdownlint](https://github.com/DavidAns - Use when: copying the rule files; required by several of the rules above. - **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_*.md and negative_*.md fixtures with explicit expected errors, verified by `test-scripts/verify_markdownlint_fixtures.py`. +- **Markdownlint fixture tests**: [md_test_files/](md_test_files/README.md) includes `positive_*.md` and `negative_*.md` fixtures with explicit expected errors, verified by `test-scripts/verify_markdownlint_fixtures.py`. - **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`. + Includes functional fix tests (`test_fix_heading_title_case.py`, `test_fix_ascii_only.py`, `test_fix_heading_numbering.py`, `test_fix_no_heading_like_lines.py`, `test_fix_one_sentence_per_line.py`) and `test_markdownlint_options.py` (rule options via config helper). - **Python linting** for repo tooling scripts: `make lint-python` (flake8, pylint, xenon/radon, vulture, bandit). See **[markdownlint-rules/README.md](markdownlint-rules/README.md)** for rule docs and configuration. ## Requirements -- Node.js and npm (for JS linting) +- `Node.js` and npm (for JS linting) - Python 3 (for repo test scripts and `make lint-python`) - [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) and config (`.markdownlint-cli2.jsonc`, `.markdownlint.yml`) when using the custom rules in another repo @@ -132,6 +143,7 @@ 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). + To auto-fix fixable violations (e.g. heading title case, ascii-only replacements, heading numbering), run `markdownlint-cli2 --fix ` or use the editor "Fix all supported markdownlint violations". ## Repository Layout @@ -160,8 +172,10 @@ npm install ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute, run tests (`make test-markdownlint`, `make test-rules`, `make test-python`), and run linting (`make lint-js`, `make lint-readmes`). Use `make ci` to run all CI checks locally. +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute, run tests (`make test-markdownlint`, `make test-rules`, `make test-python`), and run linting (`make lint-js`, `make lint-readmes`). +Use `make ci` to run all CI checks locally. ## License -MIT. See [LICENSE](LICENSE). +MIT. +See [LICENSE](LICENSE). diff --git a/eslint.config.cjs b/eslint.config.cjs index 5d6f272..3d33a16 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -17,9 +17,13 @@ module.exports = defineConfig([ globals: { ...globals.node }, }, rules: { - "max-lines": ["warn", { max: 250, skipBlankLines: true, skipComments: true }], - complexity: ["warn", { max: 10 }], - "max-depth": ["warn", 4], + "max-lines": ["warn", { + max: 500, + skipBlankLines: true, + skipComments: true, + }], + complexity: ["error", { max: 10 }], + "max-depth": ["error", 4], "max-params": ["warn", 4], "security/detect-eval-with-expression": "warn", "security/detect-non-literal-require": "warn", diff --git a/markdownlint-rules/.markdownlint.clean.yml b/markdownlint-rules/.markdownlint.clean.yml new file mode 100644 index 0000000..47d2e13 --- /dev/null +++ b/markdownlint-rules/.markdownlint.clean.yml @@ -0,0 +1,321 @@ +--- +# ============================================================================= +# .markdownlint.clean.yml — reference config with all custom rules and options +# ============================================================================= +# +# How to use this as your markdownlint config: +# +# • Copy to .markdownlint.yml and uncomment the rules you want: +# cp .markdownlint.clean.yml .markdownlint.yml +# Then edit .markdownlint.yml: uncomment the rule block(s) and set options. +# +# • Or run markdownlint with this file explicitly: +# markdownlint --config .markdownlint-clean.yml . +# (Only active entries apply; everything here is commented except default.) +# +# Rules are in alphabetical order. MD033 is grouped with allow-custom-anchors +# because allowing is required for custom anchor ids. All options are +# commented; uncomment a rule block and the options you need to override. +# +# ============================================================================= + +default: true + +# ----------------------------------------------------------------------------- +# allow-custom-anchors (custom) + MD033 (built-in; allow for anchors) +# ----------------------------------------------------------------------------- +# To override: Uncomment both allow-custom-anchors and MD033 below. Define +# allowedIdPatterns (required); optionally set strictPlacement and +# excludePathPatterns. +# +# allow-custom-anchors: allow only configured anchor id +# patterns; optional placement per pattern. +# Options: +# allowedIdPatterns: array (required); each entry regex string or +# { pattern: string, placement?: object } +# placement: headingMatch (regex), lineMatch (regex), standaloneLine (bool), +# requireAfter (["blank","fencedBlock","list"]), +# anchorImmediatelyAfterHeading (bool), maxPerSection (number) +# strictPlacement: bool (default true); enforce placement when pattern has placement set +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# allow-custom-anchors: +# allowedIdPatterns: +# - pattern: '^my-id$' +# placement: +# headingMatch: '^# .*' +# lineMatch: '^.*' +# standaloneLine: true +# requireAfter: +# - blank +# - fencedBlock +# - list +# anchorImmediatelyAfterHeading: true +# maxPerSection: 1 +# strictPlacement: true +# # excludePathPatterns: +# # - "**/*.md" + +# MD033/no-inline-html - allow only listed HTML elements (built-in). Required +# for allow-custom-anchors (). +# Options: +# allowed_elements: list of element names (e.g. "a", "br"); only these allowed +# --- +# MD033: +# allowed_elements: +# - "a" + +# ----------------------------------------------------------------------------- +# ascii-only (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the ascii-only block and set any of the options below. +# Disallow non-ASCII except in configured paths; inline code (backticks) always +# stripped. Fixable when replacement in unicodeReplacements. +# Options (all optional): +# allowedEmoji: list of chars/emoji allowed in paths matching allowedPathPatternsEmoji +# allowedPathPatternsEmoji: list of globs; files where only allowedEmoji allowed +# allowedPathPatternsUnicode: list of globs; files where any non-ASCII allowed +# allowedUnicode: list of single-char strings; allowed in all files +# allowedUnicodeReplaceDefault: bool (default false); true = use only allowedUnicode, no built-in +# allowUnicodeInCodeBlocks: bool (default true); false = check inside fenced blocks +# disallowUnicodeInCodeBlockTypes: list of block types; when allowUnicodeInCodeBlocks false, only these checked +# excludePathPatterns: list of globs; skip this rule for matching file paths +# unicodeReplacements: object or [[char, replacement], ...]; built-in defaults if omitted +# --- +# ascii-only: +# # allowedPathPatternsUnicode: +# # - "**/README.md" +# # allowedPathPatternsEmoji: +# # - "docs/**" +# # allowedEmoji: +# # - "✅" +# # - "⚠️" +# # allowedUnicode: +# # - "°" +# # - "ń" +# # allowedUnicodeReplaceDefault: false +# # allowUnicodeInCodeBlocks: true +# # disallowUnicodeInCodeBlockTypes: +# # - "text" +# # - "bash" +# # excludePathPatterns: +# # - "**/*.md" +# # unicodeReplacements: +# # "→": "->" +# # "←": "<-" + +# ----------------------------------------------------------------------------- +# document-length (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the document-length block; set maximum and/or excludePathPatterns. +# Disallow documents longer than maximum lines. +# Options: +# maximum: number (default 1500); max allowed line count; must be positive integer +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# document-length: +# # maximum: 1500 +# # excludePathPatterns: +# # - "**/long/**" + +# ----------------------------------------------------------------------------- +# fenced-code-under-heading (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; set languages (required), then any of the other options. +# Code blocks for given languages must sit under an H2–H6 heading. +# Options: +# languages: list of strings (required); info strings (e.g. go, bash) to check +# minHeadingLevel / maxHeadingLevel: numbers (default 2 and 6); heading levels that count +# maxBlocksPerHeading: number; max blocks matching languages per heading +# requireHeading: bool (default true); every applicable block must have preceding heading +# exclusive: bool (default false); when true, at most one fenced block per heading, must be one of languages +# excludePaths / includePaths: list of globs; excludePaths skips; includePaths = only those checked +# excludePathPatterns: list of globs; skip this rule (alias for excludePaths in this rule) +# --- +# fenced-code-under-heading: +# languages: +# - "go" +# - "bash" +# # minHeadingLevel: 2 +# # maxHeadingLevel: 6 +# # maxBlocksPerHeading: 1 +# # requireHeading: true +# # exclusive: false +# # excludePaths: +# # - "**/README.md" +# # includePaths: +# # - "**/*.md" +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# heading-min-words (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; set minWords and applyToLevelsAtOrBelow (required), then any optional options. +# Headings at or below a level must have at least N words (after optional numbering strip). +# Options: +# minWords: number (required); minimum word count per heading +# applyToLevelsAtOrBelow: number (required); apply to headings at this level or deeper (e.g. 4 = H1–H4) +# minLevel / maxLevel: numbers (optional); only headings in this level range checked (default 1–6) +# excludePaths / includePaths: list of globs; excludePaths skips; includePaths = only those files +# allowList: list of exact heading titles (after strip) allowed even if below minWords +# stripNumbering: bool (default true); strip leading numbering (e.g. 1.2.3) before counting and allowList match +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# heading-min-words: +# minWords: 2 +# applyToLevelsAtOrBelow: 4 +# # minLevel: 1 +# # maxLevel: 6 +# # excludePaths: +# # - "**/README.md" +# # includePaths: +# # - "**/*.md" +# # allowList: +# # - "Overview" +# # - "Summary" +# # stripNumbering: true +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# heading-numbering (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the heading-numbering block and set any options. Enforce numeric outline (e.g. 1, 1.1, 1.2). Fixable. +# Options (all optional): +# maxHeadingLevel: number 1–6 (default 6); headings at or below this level must be numbered +# maxSegmentValue: number (default 99); max value for any segment +# maxSegmentValueMinLevel: number 1–6 (default 2); min heading level for maxSegmentValue cap +# maxSegmentValueMaxLevel: number 1–6 (default 6); max heading level for maxSegmentValue cap +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# heading-numbering: +# # maxHeadingLevel: 6 +# # maxSegmentValue: 99 +# # maxSegmentValueMinLevel: 2 +# # maxSegmentValueMaxLevel: 6 +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# heading-title-case (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block and set lowercaseWords and/or excludePathPatterns as needed. AP-style headline capitalization; fixable. +# Options (all optional): +# lowercaseWords: list of strings; words that must be lowercase in middle (extends default list) +# lowercaseWordsReplaceDefault: bool (default false); true = use only lowercaseWords list, no built-in default +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# heading-title-case: +# # lowercaseWords: +# # - "a" +# # - "an" +# # - "the" +# # - "vs" +# # lowercaseWordsReplaceDefault: false +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# MD013/line-length (built-in) +# ----------------------------------------------------------------------------- +# To override: Uncomment the MD013 block and set line_length and other options as needed. Line length limits. +# Options: +# line_length: number (default 80); max characters per line for body +# heading_line_length: number (default 80); max for heading lines +# code_block_line_length: number (default 80); max for code block lines +# code_blocks: bool (default true); include code blocks in checks +# tables: bool (default true); include tables in checks +# headings: bool (default true); include headings in checks +# strict: bool (default false); enforce length even when no whitespace beyond limit +# stern: bool (default false); warn for fixable long lines but allow long lines without spaces +# --- +# MD013: +# # line_length: 80 +# # heading_line_length: 80 +# # code_block_line_length: 80 +# # code_blocks: true +# # tables: true +# # headings: true +# # strict: false +# # stern: false + +# ----------------------------------------------------------------------------- +# no-duplicate-headings-normalized (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; add excludePathPatterns if you need to skip paths. Disallow duplicate heading text after normalizing (whitespace/case). +# Options: +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# no-duplicate-headings-normalized: +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# no-empty-heading (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block and set minimumContentLines, count* options, and/or excludePathPatterns. H2+ must have content before any subheading. Per-section suppress: "". +# Options (all optional): +# minimumContentLines: number (default 1); minimum lines that count as content under each H2+ +# countBlankLinesAsContent: bool (default false); count blank lines toward minimum +# countHTMLCommentsAsContent: bool (default false); count HTML-comment-only lines +# countHtmlLinesAsContent: bool (default false); count HTML-tag-only lines (e.g.
) +# countCodeBlockLinesAsContent: bool (default true); count lines inside ```/~~~ blocks +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# no-empty-heading: +# # minimumContentLines: 1 +# # countBlankLinesAsContent: false +# # countHTMLCommentsAsContent: false +# # countHtmlLinesAsContent: false +# # countCodeBlockLinesAsContent: true +# # excludePathPatterns: +# # - "**/*_index.md" + +# ----------------------------------------------------------------------------- +# no-h1-content (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; set excludePathPatterns to skip READMEs etc. if desired. Under first h1 allow only TOC (blank lines, list-of-links, badges, HTML comments). +# Options: +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# no-h1-content: +# # excludePathPatterns: +# # - "**/README.md" + +# ----------------------------------------------------------------------------- +# no-heading-like-lines (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; set convertToHeading, defaultHeadingLevel, fixedHeadingLevel, punctuationMarks, or excludePathPatterns. Report lines that look like headings but are not (e.g. **Text:**, MD036-style). Fixable. +# Options (all optional): +# convertToHeading: bool (default false); when true, fix converts to ATX heading; when false, fix strips emphasis +# defaultHeadingLevel: number 1–6 (default 2); used when converting and there is no preceding heading +# fixedHeadingLevel: number 1–6; when set, suggested heading uses this level and ignores context +# punctuationMarks: string (default ".,;!?"); for whole-line emphasis, skip when content ends with one of these +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# no-heading-like-lines: +# # convertToHeading: false +# # defaultHeadingLevel: 2 +# # fixedHeadingLevel: 3 +# # punctuationMarks: ".,;!?" +# # excludePathPatterns: +# # - "**/*.md" + +# ----------------------------------------------------------------------------- +# one-sentence-per-line (custom) +# ----------------------------------------------------------------------------- +# To override: Uncomment the block; set continuationIndent, strictAbbreviations, and/or excludePathPatterns. Enforce one sentence per line in prose and list content. Fixable (repeated Fix all). +# Options (all optional): +# continuationIndent: number (default 4); spaces for paragraph continuation lines +# strictAbbreviations: list of strings; abbreviations that do not end a sentence (extends/replaces default) +# excludePathPatterns: list of globs; skip this rule for matching file paths +# --- +# one-sentence-per-line: +# # continuationIndent: 4 +# # strictAbbreviations: +# # - "e.g" +# # - "i.e" +# # - "etc" +# # excludePathPatterns: +# # - "**/*.md" diff --git a/markdownlint-rules/README.md b/markdownlint-rules/README.md index 0013e3c..45bee0e 100644 --- a/markdownlint-rules/README.md +++ b/markdownlint-rules/README.md @@ -15,19 +15,28 @@ - [`heading-title-case`](#heading-title-case) - [`no-duplicate-headings-normalized`](#no-duplicate-headings-normalized) - [`heading-numbering`](#heading-numbering) + - [`one-sentence-per-line`](#one-sentence-per-line) - [Shared Helper](#shared-helper) ## Overview This directory contains custom rules for [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2). In this repo they are registered in [.markdownlint-cli2.jsonc](../.markdownlint-cli2.jsonc) and configured in [.markdownlint.yml](../.markdownlint.yml). +A reference config with all rules and options (commented) is [.markdownlint.clean.yml](.markdownlint.clean.yml). You can reuse any of them in your own project; see [Reusing These Rules](#reusing-these-rules) below. +Some rules are **fixable** (heading-title-case, ascii-only, heading-numbering, no-heading-like-lines, one-sentence-per-line): they report `fixInfo` so `markdownlint-cli2 --fix` and the editor "Fix all" can apply corrections automatically. +- **Requirements:** `Node.js` and [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) (or the [markdownlint](https://github.com/DavidAnson/markdownlint) core with custom rule support). + When reusing rules, copy any helper files they depend on; see [Shared Helper](#shared-helper) for which rules require `utils.js`. - **Rule modules**: Each `*.js` file here (except `utils.js`) is a custom rule. - **Config**: Rule-specific options are set under the rule name in a markdownlint config file. You can use `.markdownlint.yml` or `.markdownlint.json` (markdownlint accepts either). + For a single file listing every rule and option in this package, see [.markdownlint.clean.yml](.markdownlint.clean.yml). Only rules that accept options are documented with a config section below. **Regex patterns in YAML:** use single quotes so backslashes are not interpreted by YAML (e.g. `'\s'` instead of `"\\s"`), avoiding unnecessary double escapes. +- **Suppressing a rule for a line:** Every custom rule supports an HTML comment override. + Put `` on its own line immediately before the line to suppress, or at the end of the violating line. + Example: `` on the previous line (or at end of the heading line) suppresses that heading's empty-section violation. ## Reusing These Rules @@ -36,7 +45,9 @@ To use one or more of these rules in another repo: 1. Create a `.markdownlint-rules` directory in that repo (if it does not exist). 2. Copy the rule file(s) you want (e.g. `ascii-only.js`, `no-heading-like-lines.js`) into `.markdownlint-rules`. 3. If a rule depends on helpers, copy those too. - Several rules use **utils.js** (see [Shared Helper](#shared-helper)); copy `utils.js` into `.markdownlint-rules` and do **not** list it in `customRules` (see below). + Most rules require `utils.js` (see [Shared Helper](#shared-helper) for the full list). + Copy `utils.js` into `.markdownlint-rules` and do **not** list it in `customRules` (see below). + **no-heading-like-lines** optionally uses `heading-title-case.js` and `heading-numbering.js` when `convertToHeading: true` (for AP title case and number prefixes); copy those into the same directory only if you want that behavior. 4. In the repo root, in `.markdownlint-cli2.jsonc` (or your config file), add the rule name(s) to the `customRules` array and set `customRulePaths` so it points at your `.markdownlint-rules` folder (see [markdownlint-cli2 custom rules](https://github.com/DavidAnson/markdownlint-cli2#custom-rules)). 5. For rules that accept options, add a section under the rule name in `.markdownlint.yml` (or `.markdownlint.json`) and set the options you need (see each rule's **Configuration** below). @@ -71,7 +82,8 @@ The same structure works in `.markdownlint.json` (use JSON object keys and array 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. + 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/`: @@ -87,8 +99,9 @@ Example for a repo that has copied rules into `.markdownlint-rules/`: "./.markdownlint-rules/heading-title-case.js", "./.markdownlint-rules/no-duplicate-headings-normalized.js", "./.markdownlint-rules/no-empty-heading.js", + "./.markdownlint-rules/no-h1-content.js", "./.markdownlint-rules/no-heading-like-lines.js", - "./.markdownlint-rules/no-h1-content.js" + "./.markdownlint-rules/one-sentence-per-line.js" ] } ``` @@ -128,14 +141,26 @@ allow-custom-anchors: **Per-pattern placement** (optional `placement` on an entry in `allowedIdPatterns`): -- **`headingMatch`** (string): Optional. Regex for the heading line. Anchor must be inside a section whose heading matches (sections tracked by heading level). -- **`lineMatch`** (string): Optional. Regex for the line content before the anchor. The line (before the anchor) must match. -- **`standaloneLine`** (boolean): Optional. If true, anchor must be the only content on its line. -- **`requireAfter`** (array): Optional. Sequence after anchor line: `["blank"]`, `["blank", "fencedBlock"]`, or `["blank", "list"]`. -- **`anchorImmediatelyAfterHeading`** (boolean): Optional. If true, anchor line must follow (with optional blank lines) a heading. When `headingMatch` is set, that heading must match it; otherwise the previous non-blank line may be any ATX heading (`#`-`######`). Works when the anchor shares a line with other content (e.g. end of a list item). -- **`maxPerSection`** (number): Optional. Max anchors of this pattern per `headingMatch` section (e.g. 1). - -Order of entries matters: the first pattern that matches the anchor id is used. Put more specific patterns (e.g. algo-step) before general ones (e.g. algo). Entries may be a plain regex string (no placement) or `{ pattern: "regex", placement: { ... } }`. +- **`headingMatch`** (string): Optional. + Regex for the heading line. + Anchor must be inside a section whose heading matches (sections tracked by heading level). +- **`lineMatch`** (string): Optional. + Regex for the line content before the anchor. + The line (before the anchor) must match. +- **`standaloneLine`** (boolean): Optional. + If true, anchor must be the only content on its line. +- **`requireAfter`** (array): Optional. + Sequence after anchor line: `["blank"]`, `["blank", "fencedBlock"]`, or `["blank", "list"]`. +- **`anchorImmediatelyAfterHeading`** (boolean): Optional. + If true, anchor line must follow (with optional blank lines) a heading. + When `headingMatch` is set, that heading must match it; otherwise the previous non-blank line may be any ATX heading (`#`-`######`). + Works when the anchor shares a line with other content (e.g. end of a list item). +- **`maxPerSection`** (number): Optional. + Max anchors of this pattern per `headingMatch` section (e.g. 1). + +Order of entries matters: the first pattern that matches the anchor id is used. +Put more specific patterns (e.g. algo-step) before general ones (e.g. algo). +Entries may be a plain regex string (no placement) or `{ pattern: "regex", placement: { ... } }`. #### Behavior (`allow-custom-anchors`) @@ -151,15 +176,55 @@ Order of entries matters: the first pattern that matches the anchor id is used. **Description:** Disallow heading-like lines that should be proper Markdown headings. -**Configuration:** None. +**Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `no-heading-like-lines` (all optional): + +```yaml +no-heading-like-lines: + convertToHeading: false # when true, fix converts to ATX heading instead of stripping emphasis + defaultHeadingLevel: 2 # level when there is no preceding heading (1-6) + fixedHeadingLevel: 3 # if set, force this level and ignore context + punctuationMarks: ".,;!?" # for whole-line emphasis, skip when content ends with one of these (default omits : so colon lines are caught) + excludePathPatterns: # optional; skip this rule for matching paths + - "**/README.md" +``` + +- **`convertToHeading`** (boolean, default `false`): When false, the default fix strips emphasis to plain text (e.g. `**Summary:**` -> `Summary:`). + When true, the fix converts the line to an ATX heading with context-aware level (one below the last preceding heading; no prior heading -> `defaultHeadingLevel`). +- **`defaultHeadingLevel`** (number 1-6, default 2): Used when converting to a heading and there is no preceding heading in the document. +- **`fixedHeadingLevel`** (number 1-6, optional): When set, the suggested heading uses this level and ignores context. +- **`punctuationMarks`** (string, default `".,;!?"`): For whole-line emphasis (`**Text**` / `*Text*`), the rule does not report when the emphasized content ends with one of these (treats as sentences). + Colon is omitted by default so lines ending in colons are always caught (rule is greedier than MD036). +- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped. + When the file path matches any pattern, the rule does not report for that file. -**Behavior:** Reports lines that look like headings but are not (e.g. `**Text:**`, `**Text**:`, `1. **Text**`, and italic variants). Prompts use of real `#` headings instead. +**Fixable:** Yes (config-controlled). +Default fix strips emphasis to plain text. +When `convertToHeading` is true, the fix converts to an ATX heading with context-aware level, adds a blank line after the heading when the next line is non-blank, and when the optional dependency files are present respects numbering (adds the correct number prefix when the section uses numbered headings) and applies AP title case to the heading text (same rules as heading-title-case). + +**Behavior:** Reports lines that look like headings but are not (e.g. `**Text:**`, `**Text**:`, `1. **Text**`, italic variants, whole-line emphasis). +Whole-line emphasis ending with a `punctuationMarks` character is not reported; default omits colon so colon lines are always caught (greedier than MD036). +fixInfo replaces with stripped text or ATX heading. +Disable MD036 to avoid duplicates and use this rule's fixInfo for `--fix`. + +#### Using Without Heading-Title-Case And/Or Heading-Numbering + +You can use `no-heading-like-lines.js` with only `utils.js`; the other rule files are optional. +For the default fix (strip emphasis), no other files are needed. +For `convertToHeading: true`: + +- If `heading-title-case.js` is not in the same rules directory, suggested headings use the extracted title as-is (no AP title case). +- If `heading-numbering.js` is not in the same rules directory, no number prefix is added to suggested headings. + When it is present, a number prefix is only added when the section already uses numbering (i.e. when the parent has numbering or at least one sibling heading at the same level has numbering; see `sectionUsesNumbering` in heading-numbering.js). + +To get full convertToHeading behavior (AP title case and numbering), copy `heading-title-case.js` and `heading-numbering.js` into the same directory as `no-heading-like-lines.js`. +The rule degrades gracefully when those files are absent. ### `no-h1-content` **File:** `no-h1-content.js` -**Description:** Under the first h1 heading, allow only table-of-contents content (blank lines, list-of-links, badges, HTML comments). No prose or other content is permitted. +**Description:** Under the first h1 heading, allow only table-of-contents content (blank lines, list-of-links, badges, HTML comments). +No prose or other content is permitted. **Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `no-h1-content`: @@ -197,11 +262,14 @@ no-empty-heading: - "**/*_index.md" # optional; skip rule for these paths ``` -- **`minimumContentLines`** (number, default `1`): Minimum number of lines that must count as content directly under each H2+ heading. Must be >= 1; invalid values fall back to 1. +- **`minimumContentLines`** (number, default `1`): Minimum number of lines that must count as content directly under each H2+ heading. + Must be >= 1; invalid values fall back to 1. - **`countBlankLinesAsContent`** (boolean, default `false`): If `true`, blank lines count toward the minimum content lines. -- **`countHTMLCommentsAsContent`** (boolean, default `false`): If `true`, lines that are only an HTML comment (other than the suppress comment) count toward the minimum. The suppress comment `` never counts as content. +- **`countHTMLCommentsAsContent`** (boolean, default `false`): If `true`, lines that are only an HTML comment (other than the suppress comment) count toward the minimum. + The suppress comment `` never counts as content. - **`countHtmlLinesAsContent`** (boolean, default `false`): If `true`, lines that are only an HTML tag (e.g. `
`, `
...
`) count toward the minimum. -- **`countCodeBlockLinesAsContent`** (boolean, default `true`): If `false`, lines inside fenced code blocks (` ``` `or `~~~`) do not count toward the minimum. When `true`, they do (default). +- **`countCodeBlockLinesAsContent`** (boolean, default `true`): If `false`, lines inside fenced code blocks (` ``` `or `~~~`) do not count toward the minimum. + When `true`, they do (default). - **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped. Behavior: @@ -210,7 +278,8 @@ Behavior: Content under subheadings does **not** count for the parent heading. Which line types count is controlled by the options above (by default: prose and code block lines; blank, HTML-comment, and HTML-tag lines do not). - **Suppress per section:** A section is allowed without meeting the minimum if it contains a line that is solely the comment `` (optional whitespace around or inside the comment). - The comment must be on its own line. No other HTML comment format suppresses the rule. + The comment must be on its own line. + No other HTML comment format suppresses the rule. - When the file path matches any of `excludePathPatterns`, the rule is skipped for the whole file. ### `document-length` @@ -224,11 +293,15 @@ Behavior: ```yaml document-length: maximum: 1500 # optional; default 1500 + # excludePathPatterns: ["**/long-docs/**"] # optional; skip rule for matching paths ``` -- **`maximum`** (number, default `1500`): Maximum allowed line count. Must be a positive integer. +- **`maximum`** (number, default `1500`): Maximum allowed line count. + Must be a positive integer. +- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped. -**Behavior:** When the file has more than `maximum` lines, the rule reports a single error on line 1. The message includes the actual line count and the maximum and suggests splitting into smaller files. +**Behavior:** When the file has more than `maximum` lines, the rule reports a single error on line 1. +The message includes the actual line count and the maximum and suggests splitting into smaller files. ### `ascii-only` @@ -236,6 +309,10 @@ document-length: **Description:** Disallow non-ASCII except in configured paths; optional replacement suggestions via `unicodeReplacements`. +**Fixable:** Yes, when a replacement is configured in `unicodeReplacements` (or the default map). +Auto-fix replaces the disallowed character with that replacement. +Not fixable when no replacement is available. + **Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `ascii-only`: Example: minimal (default letters plus path/emoji) @@ -313,20 +390,24 @@ ascii-only: # disallowUnicodeInCodeBlockTypes: ["text", "bash"] # when allowUnicodeInCodeBlocks false unicodeReplacements: "→": "->" - "—": "--" + "—": "-" ``` - **`allowedPathPatternsUnicode`** (list of strings, default none): Glob patterns for files where any non-ASCII is allowed. - **`allowedPathPatternsEmoji`** (list of strings, default none): Glob patterns for files where only `allowedEmoji` characters are allowed. - **`allowedEmoji`** (list of strings, default none): Emoji (or other chars) allowed in paths matching `allowedPathPatternsEmoji`; each entry may be multi-codepoint (e.g. ⚠️); all code points are allowed. -- **`allowedUnicode`** (list of single-character strings, optional): Characters allowed in all files (global allowlist). By default these **extend** the built-in set of common non-English letters (e.g. é, ï, è, ñ, ç). Set **`allowedUnicodeReplaceDefault: true`** to **override** and use only your list (no default set). +- **`allowedUnicode`** (list of single-character strings, optional): Characters allowed in all files (global allowlist). + By default these **extend** the built-in set of common non-English letters (e.g. é, ï, è, ñ, ç). + Set **`allowedUnicodeReplaceDefault: true`** to **override** and use only your list (no default set). - **`allowedUnicodeReplaceDefault`** (boolean, default false): When true, only `allowedUnicode` is used (no built-in default set). - **`allowUnicodeInCodeBlocks`** (boolean, default true): When true, lines inside fenced code blocks are not checked. When false, code blocks are checked (or only those in `disallowUnicodeInCodeBlockTypes` if that list is non-empty). - **`disallowUnicodeInCodeBlockTypes`** (list of strings, default empty): When `allowUnicodeInCodeBlocks` is false, only fenced blocks whose info string (e.g. `text`, `bash`) is in this list are checked; block type is the first word after the opening fence. When empty, all code blocks are checked. -- **`unicodeReplacements`** (object or array of [char, replacement], default built-in): Map of single Unicode character to suggested ASCII replacement in error messages. When omitted, rule uses built-in defaults (arrows, quotes, <=, >=, \*). +- **`unicodeReplacements`** (object or array of [char, replacement], default built-in): + Map of single Unicode character to suggested ASCII replacement in error messages. + When omitted, rule uses built-in defaults (arrows, quotes, em dash, <=, >=, \*). Glob matching supports `**` (any path) and `*` (within a segment). Paths are normalized (forward slashes, leading `./` removed). @@ -339,14 +420,17 @@ Relative patterns (no leading `/` or `*`) match both path-prefix (e.g. `dev_docs - If the file path matches `allowedPathPatternsEmoji`, only characters in `allowedEmoji` (and Unicode variation selectors U+FE00-U+FE0F) are allowed; other non-ASCII is reported per occurrence. - Characters allowed in all files: the default set (e.g. é, ï, ñ, ç) plus `allowedUnicode` when **extend** (default), or only `allowedUnicode` when `allowedUnicodeReplaceDefault: true`. - Non-ASCII is detected by code-point iteration (surrogate pairs treated as one character) and compared after NFC normalization. -- **One error per disallowed character:** each violation highlights only that character (range) on the line. The detail names the character, its code point (e.g. U+2192), and the suggested replacement when present in `unicodeReplacements`. -- Inline code (backticks) is stripped before scanning. Fenced code blocks are skipped by default; set `allowUnicodeInCodeBlocks: false` to check them, and optionally `disallowUnicodeInCodeBlockTypes` to restrict which block types (e.g. `text`, `bash`) are checked. +- **One error per disallowed character:** each violation highlights only that character (range) on the line. + The detail names the character, its code point (e.g. U+2192), and the suggested replacement when present in `unicodeReplacements`. +- Inline code (backticks) is stripped before scanning. + Fenced code blocks are skipped by default; set `allowUnicodeInCodeBlocks: false` to check them, and optionally `disallowUnicodeInCodeBlockTypes` to restrict which block types (e.g. `text`, `bash`) are checked. ### `fenced-code-under-heading` **File:** `fenced-code-under-heading.js` -**Description:** For specified languages (e.g. `go`), every fenced code block must sit under an H2-H6 heading, and each heading may have at most a configured number of such blocks. Use when docs must group code under clear section headings. +**Description:** For specified languages (e.g. `go`), every fenced code block must sit under an H2-H6 heading, and each heading may have at most a configured number of such blocks. +Use when docs must group code under clear section headings. **Configuration:** In `.markdownlint.yml` under `fenced-code-under-heading`: @@ -363,27 +447,35 @@ fenced-code-under-heading: # includePaths: ["**/*.md"] ``` -- **`languages`** (array of strings, required): Info strings (e.g. `go`, `bash`) to which the rule applies. Only blocks whose first word after the opening fence matches an entry are checked. -- **`minHeadingLevel`** / **`maxHeadingLevel`** (numbers, default 2 and 6): Heading levels that count (e.g. H2-H6). Blocks must fall under a heading in this range. -- **`maxBlocksPerHeading`** (number, optional): Maximum fenced blocks of the given languages per heading; excess blocks are reported. Only blocks matching `languages` count; other block types are allowed unless `exclusive` is true. +- **`languages`** (array of strings, required): Info strings (e.g. `go`, `bash`) to which the rule applies. + Only blocks whose first word after the opening fence matches an entry are checked. +- **`minHeadingLevel`** / **`maxHeadingLevel`** (numbers, default 2 and 6): Heading levels that count (e.g. H2-H6). + Blocks must fall under a heading in this range. +- **`maxBlocksPerHeading`** (number, optional): Maximum fenced blocks of the given languages per heading; excess blocks are reported. + Only blocks matching `languages` count; other block types are allowed unless `exclusive` is true. - **`requireHeading`** (boolean, default true): When true, every applicable block must be under a heading; blocks before any heading or after the last heading are reported. -- **`exclusive`** (boolean, default false): When true, at most one fenced code block (of any language) per heading, and that block must be one of the configured languages. Replaces the per-language limit with a single-block-per-heading rule. -- **`excludePaths`** / **`includePaths`** (arrays of globs, optional): Path filters; see [Shared Helper](#shared-helper). When `includePaths` is set, only those paths are checked; `excludePaths` always skips matching paths. +- **`exclusive`** (boolean, default false): When true, at most one fenced code block (of any language) per heading, and that block must be one of the configured languages. + Replaces the per-language limit with a single-block-per-heading rule. +- **`excludePaths`** / **`includePaths`** (arrays of globs, optional): Path filters; see [Shared Helper](#shared-helper). + When `includePaths` is set, only those paths are checked; `excludePaths` always skips matching paths. #### Behavior (`fenced-code-under-heading`) -The rule scans lines for ATX headings and fenced code blocks. Block type is the first word of the info string (e.g. `go` for ` ```go `). +The rule scans lines for ATX headings and fenced code blocks. +Block type is the first word of the info string (e.g. `go` for ` ```go `). When **`exclusive`** is false: only blocks whose type is in `languages` are validated; each must have a preceding heading at a level between `minHeadingLevel` and `maxHeadingLevel`. If `maxBlocksPerHeading` is set, each heading is allowed only that many blocks of the given languages (other languages do not count). -When **`exclusive`** is true: every fenced block is considered; each heading may have at most one block total, and that block must be one of the configured languages. Multiple blocks of any type, or a single block not in `languages`, are reported. +When **`exclusive`** is true: every fenced block is considered; each heading may have at most one block total, and that block must be one of the configured languages. +Multiple blocks of any type, or a single block not in `languages`, are reported. ### `heading-min-words` **File:** `heading-min-words.js` -**Description:** Headings at or below a given level must have at least N words (after optional stripping of numbering). Use when you want to avoid single-word or empty-looking headings (e.g. "Overview" alone). +**Description:** Headings at or below a given level must have at least N words (after optional stripping of numbering). +Use when you want to avoid single-word or empty-looking headings (e.g. "Overview" alone). **Configuration:** In `.markdownlint.yml` under `heading-min-words`: @@ -409,14 +501,20 @@ heading-min-words: #### Behavior (`heading-min-words`) -For each ATX heading in the level range, the title is normalized (optional numbering stripped, then split on whitespace). If the resulting word count is less than `minWords` and the title is not in `allowList`, the rule reports an error. Path filters apply before any check. +For each ATX heading in the level range, the title is normalized (optional numbering stripped, then split on whitespace). +If the resulting word count is less than `minWords` and the title is not in `allowList`, the rule reports an error. +Path filters apply before any check. ### `heading-title-case` **File:** `heading-title-case.js` **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 `(` / `[`. +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 `(` / `[`. + +**Fixable:** Yes. +Auto-fix corrects each violating word to AP title case (lowercase or capitalize per AP rules). **Configuration:** In `.markdownlint.yml` (or `.markdownlint.json`) under `heading-title-case`: @@ -447,6 +545,7 @@ Content inside inline code (backticks) is ignored. - **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. +- **Phase labels:** A single letter immediately after the word "Phase" (e.g. `Phase A:`, `Phase B`) is treated as a label and kept capitalized, so the article "a" in the default list does not force lowercase in that context. - **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`). @@ -470,6 +569,10 @@ The first occurrence is the reference; duplicates are reported with the line num **Description:** Enforces structure and consistency of numbered headings: segment count by numbering root; numbering sequential within each section; period style consistent within section; optional max segment value and max heading level. +**Fixable:** Yes, for wrong sequence, missing number prefix, wrong segment count, and period style inconsistency. +Auto-fix replaces or inserts the correct number prefix (and period) to match sibling order and section style. +Not fixable: max segment value exceeded, max heading level (structural). + **Configuration:** In `.markdownlint.yml` under `heading-numbering` (all optional): ```yaml @@ -491,18 +594,64 @@ heading-numbering: Example: H2 under doc root -> 1 segment; H3 under unnumbered `## Section` -> 1 segment; H4 under `### 1. First` -> 1 segment. Headings without a numeric prefix are ignored. 2. **Section-scoped consistency:** For each section (siblings under the same parent), if any sibling has numbering then all siblings at that level must be numbered sequentially and use consistent period style (all `## 1. Title` or all `## 1 Title`). - **Index base:** Default is 1-based (e.g. 1., 2., 3.). If the first numbered sibling in a section has last segment `0` (e.g. `0.`, `0.0.`, `1.0.`), that section is treated as 0-based (0., 1., 2. or 0.0., 0.1., etc.) and no sequence error is reported. + **Index base:** Default is 1-based (e.g. 1., 2., 3.). + If the first numbered sibling in a section has last segment `0` (e.g. `0.`, `0.0.`, `1.0.`), that section is treated as 0-based (0., 1., 2. or 0.0., 0.1., etc.) and no sequence error is reported. Unnumbered siblings in a numbered section are reported. 3. **Max heading level:** If `maxHeadingLevel` is set, only headings at that level or lower are checked; deeper headings are skipped. 4. **Max segment value:** If `maxSegmentValue` is set, each numeric segment (in the level range when min/max level options are set) must be <= that value. +### `one-sentence-per-line` + +**File:** `one-sentence-per-line.js` + +**Description:** Enforce one sentence per line in prose and list content. +Lines with multiple sentences are reported; the rule skips fenced code, front matter, link-reference definitions, table rows, ATX headings, thematic breaks, and blank lines. +Sentence boundaries are detected conservatively: periods/question marks/exclamation followed by space, while avoiding decimals (e.g. `3.14`), common abbreviations (e.g. `e.g.`, `i.e.`), and content inside inline code or link text. + +**Fixable:** Yes. +One violation per line with multiple sentences; fix splits all sentences in one pass (newline + continuation indent per sentence). +When the base line has no leading indent, continuation lines have no indent. +List items use list-body indent for continuation; indented paragraphs use configurable `continuationIndent`. + +**Configuration:** In `.markdownlint.yml` under `one-sentence-per-line` (all optional): + +```yaml +one-sentence-per-line: + continuationIndent: 4 + # strictAbbreviations: ["e.g", "i.e", "etc"] + # excludePathPatterns: ["**/README.md"] +``` + +- **`continuationIndent`** (number, default 4): Spaces for continuation lines when the paragraph is indented; when the base line has no leading indent, continuation lines have no indent (0). + List items always use list-body indent. +- **`strictAbbreviations`** (array of strings, optional): Abbreviations that do not end a sentence (no trailing period in value, e.g. `e.g`). + When set, replaces the built-in set; when omitted, the rule uses a default set (e.g., i.e., etc., Dr., Mr., U.S., ...). +- **`excludePathPatterns`** (array of globs, optional): Skip this rule for matching file paths. + +#### Behavior (`one-sentence-per-line`) + +- **Prose lines:** The rule iterates only over "prose" lines: outside fenced code, outside front matter (YAML between `---` at start of file), and skips link-reference definitions, table rows (two consecutive lines with `|`), ATX headings, thematic breaks, and blank lines. +- **List/paragraph context:** For each prose line, leading indent and list markers (numbered `1.`, bullet `-`/`*`/`+`) are detected; continuation lines use the same indent as the list body (or `continuationIndent` for paragraphs). +- **Sentence detection:** Content is scanned after stripping inline code; bracket and parenthesis depth (e.g. links) are ignored for sentence-end detection. + A period/question/exclamation is only a sentence end when followed by at least one space (or EOL); e.g. filenames like `file.name` or `config.json` do not trigger a split. + After optional closing quotes, a period followed by space then a letter/digit is a candidate; it is skipped when the preceding token is a decimal digit or a known abbreviation (including "e.g." when the next token is "g" etc.). + ## Shared Helper -**utils.js** is not a rule; it provides utilities used by several rules. +`utils.js` is not a rule; it provides utilities used by all custom rules in this repo. Do not list it in `customRules` in `.markdownlint-cli2.jsonc`. -When reusing rules that use it, copy `utils.js` into your `.markdownlint-rules` (see [Reusing These Rules](#reusing-these-rules)). +When reusing any rule, copy `utils.js` into your `.markdownlint-rules` (see [Reusing These Rules](#reusing-these-rules)). + +**All custom rules in this repo depend on `utils.js`** (for `pathMatchesAny` and/or other helpers): allow-custom-anchors, ascii-only, document-length, fenced-code-under-heading, heading-min-words, heading-numbering, heading-title-case, no-duplicate-headings-normalized, no-empty-heading, no-heading-like-lines, no-h1-content, one-sentence-per-line. + +**All custom rules accept `excludePathPatterns`** (optional list of glob patterns). +When the file path matches any pattern, the rule is skipped for that file. +This uses `pathMatchesAny` from `utils.js`. + +- **HTML comment suppress:** `isRuleSuppressedByComment(lines, lineNumber, ruleName)` - returns true when the line or the previous line contains `` (used by all rules for per-line override). + Also accepts markdownlint's cleared form (comment body replaced with dots). -- **Heading and content:** `extractHeadings`, `iterateNonFencedLines`, `stripInlineCode`, `parseHeadingNumberPrefix`, `normalizeHeadingTitleForDup`, `normalizedTitleForDuplicate`, `RE_ATX_HEADING`, `RE_NUMBERING_PREFIX`. -- **Path/glob matching:** `globToRegExp`, `matchGlob`, `pathMatchesAny` - used for path-pattern options (e.g. ascii-only `allowedPathPatternsUnicode`). +- **Heading and content:** `extractHeadings`, `iterateNonFencedLines`, `iterateProseLines`, `stripInlineCode`, `parseHeadingNumberPrefix`, `normalizeHeadingTitleForDup`, `normalizedTitleForDuplicate`, `RE_ATX_HEADING`, `RE_NUMBERING_PREFIX`. +- **Path/glob matching:** `globToRegExp`, `matchGlob`, `pathMatchesAny` - used for `excludePathPatterns` and other path options (e.g. ascii-only `allowedPathPatternsUnicode`). Supports `**` and `*`; paths normalized to forward slashes; relative patterns match path prefix or mid-path. - **Fence parsing:** `parseFenceInfo`, `iterateLinesWithFenceInfo` - used to detect fenced code block type (e.g. ascii-only skips content; fenced-code-under-heading finds blocks by language). diff --git a/markdownlint-rules/allow-custom-anchors.js b/markdownlint-rules/allow-custom-anchors.js index 29ee22d..2fb913f 100644 --- a/markdownlint-rules/allow-custom-anchors.js +++ b/markdownlint-rules/allow-custom-anchors.js @@ -1,6 +1,6 @@ "use strict"; -const { stripInlineCode } = require("./utils.js"); +const { isRuleSuppressedByComment, pathMatchesAny, stripInlineCode } = require("./utils.js"); /** * Safely create a RegExp from a string. Returns null for invalid or empty input. @@ -302,14 +302,17 @@ function applyAnchorLine(state) { const line = lines[index]; const scanLine = stripInlineCode(line); const basicErr = getBasicAnchorError(scanLine, line, lineNumber, allowedPatterns); - if (basicErr) { onError(basicErr); return; } + if (basicErr) { + if (!isRuleSuppressedByComment(lines, lineNumber, "allow-custom-anchors")) onError(basicErr); + return; + } const id = scanLine.match(ANCHOR_TAG_RE)[1]; const matchIndex = allowedEntries.findIndex((e) => e.pattern.test(id)); const rule = allowedEntries[matchIndex].placement; const placementErr = strictPlacement && rule ? getPlacementError({ lineNumber, line, trimmed: line.trim(), id, matchIndex, index, rule, sectionStack: state.sectionStack, sectionAnchorCount: state.sectionAnchorCount, lines }) : null; - if (placementErr) onError(placementErr); + if (placementErr && !isRuleSuppressedByComment(lines, lineNumber, "allow-custom-anchors")) onError(placementErr); } /** @@ -344,6 +347,12 @@ function processLine(state) { * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { + const filePath = params.name || ""; + const block = params.config?.["allow-custom-anchors"] ?? params.config ?? {}; + const excludePatterns = block.excludePathPatterns; + if (Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns)) { + return; + } const { allowedEntries, strictPlacement } = getConfig(params); const allowedPatterns = allowedEntries.map((e) => e.pattern); const lines = params.lines; diff --git a/markdownlint-rules/ascii-only.js b/markdownlint-rules/ascii-only.js index 67fff97..b48f719 100644 --- a/markdownlint-rules/ascii-only.js +++ b/markdownlint-rules/ascii-only.js @@ -1,6 +1,7 @@ "use strict"; const { + isRuleSuppressedByComment, iterateLinesWithFenceInfo, iterateNonFencedLines, pathMatchesAny, @@ -23,6 +24,8 @@ const DEFAULT_UNICODE_REPLACEMENTS = { "\u201D": "\"", "\u2019": "'", "\u2018": "'", + "\u2013": "-", // en dash + "\u2014": "-", // em dash }; /** @@ -267,6 +270,33 @@ function shouldCheckFencedLine(inFencedBlock, blockType, disallowTypes) { return disallowTypes.has(blockType); } +function shouldSkipByPath(filePath, block) { + const excludePatterns = block.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function reportDisallowedOccurrences(lineNumber, line, ctx, onError) { + const scan = stripInlineCode(line); + if (!hasNonAscii(scan)) return; + if (ctx.allowUnicode) return; + if (ctx.allowEmojiOnly && onlyAllowedEmoji(scan, ctx.allowedEmojiSet)) return; + + for (const { startIndex, char, length } of getDisallowedOccurrences( + scan, ctx.allowEmojiOnly, ctx.allowedUnicodeSet, ctx.allowedEmojiSet, + )) { + if (isRuleSuppressedByComment(ctx.lines, lineNumber, "ascii-only")) return; + const column = startIndex + 1; + const replacement = ctx.config.unicodeReplacements.get(char); + onError({ + lineNumber, + detail: buildOccurrenceDetail(char, replacement, ctx.allowEmojiOnly, ctx.config), + context: line, + range: [column, length], + ...(replacement != null && replacement !== "" && { fixInfo: { editColumn: column, deleteCount: length, insertText: replacement } }), + }); + } +} + /** * markdownlint rule: disallow non-ASCII except in configured paths; optional * replacement suggestions via unicodeReplacements. Paths can allow full Unicode @@ -279,42 +309,30 @@ function shouldCheckFencedLine(inFencedBlock, blockType, disallowTypes) { */ function ruleFunction(params, onError) { const filePath = params.name || ""; - const config = getConfig(params); - const allowUnicode = pathMatchesAny(filePath, config.allowedPathPatternsUnicode); - const allowEmojiOnly = pathMatchesAny(filePath, config.allowedPathPatternsEmoji); - const allowedEmojiSet = toCharSet(config.allowedEmoji); - const allowedUnicodeSet = config.allowedUnicode; + const block = params.config?.["ascii-only"] ?? params.config ?? {}; + if (shouldSkipByPath(filePath, block)) return; - const checkLine = (lineNumber, line) => { - const scan = stripInlineCode(line); - if (!hasNonAscii(scan)) return; - if (allowUnicode) return; - if (allowEmojiOnly && onlyAllowedEmoji(scan, allowedEmojiSet)) return; - - for (const { startIndex, char, length } of getDisallowedOccurrences( - scan, allowEmojiOnly, allowedUnicodeSet, allowedEmojiSet, - )) { - const column = startIndex + 1; - const replacement = config.unicodeReplacements.get(char); - onError({ - lineNumber, - detail: buildOccurrenceDetail(char, replacement, allowEmojiOnly, config), - context: line, - range: [column, length], - }); - } + const config = getConfig({ config: block }); + const lines = params.lines; + const ctx = { + config, + lines, + allowUnicode: pathMatchesAny(filePath, config.allowedPathPatternsUnicode), + allowEmojiOnly: pathMatchesAny(filePath, config.allowedPathPatternsEmoji), + allowedEmojiSet: toCharSet(config.allowedEmoji), + allowedUnicodeSet: config.allowedUnicode, }; if (config.allowUnicodeInCodeBlocks) { for (const { lineNumber, line } of iterateNonFencedLines(params.lines)) { - checkLine(lineNumber, line); + reportDisallowedOccurrences(lineNumber, line, ctx, onError); } return; } for (const { lineNumber, line, inFencedBlock, blockType } of iterateLinesWithFenceInfo(params.lines)) { if (!shouldCheckFencedLine(inFencedBlock, blockType, config.disallowUnicodeInCodeBlockTypes)) continue; - checkLine(lineNumber, line); + reportDisallowedOccurrences(lineNumber, line, ctx, onError); } } diff --git a/markdownlint-rules/document-length.js b/markdownlint-rules/document-length.js index cf8b6d0..622f31d 100644 --- a/markdownlint-rules/document-length.js +++ b/markdownlint-rules/document-length.js @@ -1,5 +1,21 @@ "use strict"; +const { isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js"); + +function getBlock(params) { + return params.config?.["document-length"] ?? params.config ?? {}; +} + +function shouldSkipByPath(filePath, block) { + const excludePatterns = block.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function getMaximum(block, fullConfig) { + const raw = block.maximum ?? fullConfig?.maximum; + return typeof raw === "number" && Number.isInteger(raw) && raw >= 1 ? raw : 1500; +} + /** * markdownlint rule: disallow documents longer than a configured number of lines. * Reports a single error on line 1 when the file exceeds the maximum. @@ -8,14 +24,14 @@ * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { - const lines = params.lines; - const raw = params.config?.maximum; - const maximum = - typeof raw === "number" && Number.isInteger(raw) && raw >= 1 ? raw : 1500; + const filePath = params.name || ""; + const block = getBlock(params); + if (shouldSkipByPath(filePath, block)) return; - if (lines.length <= maximum) { - return; - } + const lines = params.lines; + const maximum = getMaximum(block, params.config); + if (lines.length <= maximum) return; + if (isRuleSuppressedByComment(lines, 1, "document-length")) return; onError({ lineNumber: 1, diff --git a/markdownlint-rules/fenced-code-under-heading.js b/markdownlint-rules/fenced-code-under-heading.js index b13e5d7..771157e 100644 --- a/markdownlint-rules/fenced-code-under-heading.js +++ b/markdownlint-rules/fenced-code-under-heading.js @@ -1,6 +1,6 @@ "use strict"; -const { parseFenceInfo, pathMatchesAny } = require("./utils.js"); +const { isRuleSuppressedByComment, parseFenceInfo, pathMatchesAny } = require("./utils.js"); const DEFAULT_MIN_HEADING_LEVEL = 2; const DEFAULT_MAX_HEADING_LEVEL = 6; @@ -72,14 +72,15 @@ function processAtxLine(trimmed, lineNumber, opts, headings) { } /** - * Single pass: find all H2–H6 in range and all opening fence lines for configured languages. + * Single pass: find all headings in range, all ATX headings (any level), and opening fence lines. * * @param {string[]} lines * @param {{ minHeadingLevel: number, maxHeadingLevel: number, languages: string[] }} opts - * @returns {{ headings: { lineNumber: number, level: number }[], blocks: { lineNumber: number, language: string }[] }} + * @returns {{ headings: { lineNumber: number, level: number }[], allHeadings: { lineNumber: number, level: number }[], blocks: { lineNumber: number, language: string }[] }} */ function findHeadingsAndBlocks(lines, opts) { const headings = []; + const allHeadings = []; const blocks = []; const state = { inFence: false, fenceMarker: null, fenceLen: 0 }; @@ -92,10 +93,12 @@ function findHeadingsAndBlocks(lines, opts) { } if (!state.inFence) { processAtxLine(trimmed, lineNumber, opts, headings); + const m = trimmed.match(RE_ATX); + if (m) allHeadings.push({ lineNumber, level: m[1].length }); } } - return { headings, blocks }; + return { headings, allHeadings, blocks }; } /** @@ -136,7 +139,7 @@ function findAllBlocks(lines) { } /** - * For each block, get the last heading (H2–H6 in range) that appears before the block. + * For each block, get the last heading (in range) that appears before the block. * * @param {{ lineNumber: number, level: number }[]} headings - Sorted by lineNumber * @param {number} blockLine @@ -151,6 +154,22 @@ function precedingHeading(headings, blockLine) { return last; } +/** + * Get the immediately preceding heading (any level) before blockLine. + * + * @param {{ lineNumber: number, level: number }[]} allHeadings - Sorted by lineNumber + * @param {number} blockLine + * @returns {{ lineNumber: number, level: number }|null} + */ +function precedingHeadingAnyLevel(allHeadings, blockLine) { + let last = null; + for (const h of allHeadings) { + if (h.lineNumber >= blockLine) break; + last = h; + } + return last; +} + /* c8 ignore start -- path filter branches covered by tests; per-file threshold met by other code */ function shouldSkipFile(filePath, opts) { const includePaths = Array.isArray(opts.includePaths) ? opts.includePaths : []; @@ -162,10 +181,13 @@ function shouldSkipFile(filePath, opts) { /* c8 ignore stop */ function reportBlocksWithoutHeading(ctx) { - const { blocks, headings, opts, lines, onError } = ctx; + const { blocks, allHeadings, opts, lines, onError } = ctx; for (const block of blocks) { - const headingLine = precedingHeading(headings, block.lineNumber); - if (headingLine != null || !opts.requireHeading) continue; + const immediate = precedingHeadingAnyLevel(allHeadings, block.lineNumber); + if (!opts.requireHeading) continue; + const validLevel = immediate != null && immediate.level >= opts.minHeadingLevel && immediate.level <= opts.maxHeadingLevel; + if (validLevel) continue; + if (isRuleSuppressedByComment(lines, block.lineNumber, "fenced-code-under-heading")) continue; onError({ lineNumber: block.lineNumber, detail: `Fenced code block (${block.language}) must have an H${opts.minHeadingLevel}-H${opts.maxHeadingLevel} heading above it.`, @@ -185,6 +207,7 @@ function reportExcessBlocksPerHeading(ctx) { for (const lineNumbers of blocksByHeading.values()) { for (let i = opts.maxBlocksPerHeading; i < lineNumbers.length; i++) { const lineNumber = lineNumbers[i]; + if (isRuleSuppressedByComment(lines, lineNumber, "fenced-code-under-heading")) continue; onError({ lineNumber, detail: `At most ${opts.maxBlocksPerHeading} fenced code block(s) of the configured language(s) per heading (found ${lineNumbers.length} under same heading).`, @@ -194,27 +217,24 @@ function reportExcessBlocksPerHeading(ctx) { } } -function reportExclusiveViolations(ctx) { - const { allBlocks, headings, opts, lines, onError } = ctx; - const blocksByHeading = new Map(); - for (const block of allBlocks) { - const key = precedingHeading(headings, block.lineNumber) ?? 0; - if (!blocksByHeading.has(key)) blocksByHeading.set(key, []); - blocksByHeading.get(key).push(block); - } +function reportExclusiveBlockErrors(blockList, ctx) { + const { opts, lines, onError } = ctx; const langList = opts.languages.join(", "); - for (const blockList of blocksByHeading.values()) { - if (blockList.length > 1) { - for (let i = 1; i < blockList.length; i++) { - const { lineNumber } = blockList[i]; - onError({ - lineNumber, - detail: `Only one fenced code block allowed per heading when exclusive is enabled (found ${blockList.length}).`, - context: lines[lineNumber - 1], - }); - } - } else if (blockList.length === 1 && !opts.languages.includes(blockList[0].language)) { - const { lineNumber, language } = blockList[0]; + if (blockList.length > 1) { + for (let i = 1; i < blockList.length; i++) { + const { lineNumber } = blockList[i]; + if (isRuleSuppressedByComment(lines, lineNumber, "fenced-code-under-heading")) continue; + onError({ + lineNumber, + detail: `Only one fenced code block allowed per heading when exclusive is enabled (found ${blockList.length}).`, + context: lines[lineNumber - 1], + }); + } + return; + } + if (blockList.length === 1 && !opts.languages.includes(blockList[0].language)) { + const { lineNumber, language } = blockList[0]; + if (!isRuleSuppressedByComment(lines, lineNumber, "fenced-code-under-heading")) { const displayLang = language || "(no language)"; onError({ lineNumber, @@ -225,6 +245,20 @@ function reportExclusiveViolations(ctx) { } } +function reportExclusiveViolations(ctx) { + const { allBlocks, headings, lines, onError, opts } = ctx; + const blocksByHeading = new Map(); + for (const block of allBlocks) { + const key = precedingHeading(headings, block.lineNumber) ?? 0; + if (!blocksByHeading.has(key)) blocksByHeading.set(key, []); + blocksByHeading.get(key).push(block); + } + const reportCtx = { opts, lines, onError }; + for (const blockList of blocksByHeading.values()) { + reportExclusiveBlockErrors(blockList, reportCtx); + } +} + /** * markdownlint rule: fenced code blocks with specified language(s) must have an H2–H6 heading * above them; at most N blocks per heading (configurable). @@ -242,8 +276,8 @@ function ruleFunction(params, onError) { if (opts.languages.length === 0 || shouldSkipFile(filePath, opts)) return; /* c8 ignore stop */ - const { headings, blocks } = findHeadingsAndBlocks(lines, opts); - reportBlocksWithoutHeading({ blocks, headings, opts, lines, onError }); + const { headings, allHeadings, blocks } = findHeadingsAndBlocks(lines, opts); + reportBlocksWithoutHeading({ blocks, allHeadings, opts, lines, onError }); if (opts.exclusive) { const allBlocks = findAllBlocks(lines); reportExclusiveViolations({ allBlocks, headings, opts, lines, onError }); diff --git a/markdownlint-rules/heading-min-words.js b/markdownlint-rules/heading-min-words.js index 4a02100..ee17267 100644 --- a/markdownlint-rules/heading-min-words.js +++ b/markdownlint-rules/heading-min-words.js @@ -1,6 +1,6 @@ "use strict"; -const { extractHeadings, parseHeadingNumberPrefix, pathMatchesAny } = require("./utils.js"); +const { extractHeadings, isRuleSuppressedByComment, parseHeadingNumberPrefix, pathMatchesAny } = require("./utils.js"); /** * Normalize config: minWords required; optional applyToLevelsAtOrBelow, minLevel/maxLevel, @@ -93,6 +93,29 @@ function isAllowedSingleWord(title, allowList) { return allowList.some((a) => a.trim().toLowerCase() === single); } +/** + * Report error if a single heading violates min-words (or allowList for single-word). + * + * @param {{ lineNumber: number, level: number, rawText: string }} h - Heading + * @param {object} opts - Normalized config + * @param {string[]} lines - Document lines + * @param {function(object): void} onError - Callback to report an error + */ +function checkHeading(h, opts, lines, onError) { + if (!levelInScope(h.level, opts)) return; + const title = getTitleForWordCount(h.rawText, opts.stripNumbering); + const count = wordCount(title); + /* c8 ignore next 2 -- branch: allow by minWords or allowList */ + const allowed = count >= opts.minWords || (count === 1 && isAllowedSingleWord(title, opts.allowList)); + if (allowed) return; + if (isRuleSuppressedByComment(lines, h.lineNumber, "heading-min-words")) return; + onError({ + lineNumber: h.lineNumber, + detail: `Heading at or below this level must have at least ${opts.minWords} word(s) in the title (found ${count}).`, + context: lines[h.lineNumber - 1], + }); +} + /** * markdownlint rule: headings at or below a configurable level must have at least N words * in the title (after optional numbering strip). Optional allowList for single-word titles. @@ -100,7 +123,6 @@ function isAllowedSingleWord(title, allowList) { * @param {object} params - markdownlint params (lines, config, name) * @param {function(object): void} onError - Callback to report an error */ -// eslint-disable-next-line complexity -- path/config/level/word/allowList branches function ruleFunction(params, onError) { const lines = params.lines; const filePath = params.name || ""; @@ -111,17 +133,7 @@ function ruleFunction(params, onError) { const headings = extractHeadings(lines); for (const h of headings) { - if (!levelInScope(h.level, opts)) continue; - const title = getTitleForWordCount(h.rawText, opts.stripNumbering); - const count = wordCount(title); - /* c8 ignore next 2 -- branch: allow by minWords or allowList */ - const allowed = count >= opts.minWords || (count === 1 && isAllowedSingleWord(title, opts.allowList)); - if (allowed) continue; - onError({ - lineNumber: h.lineNumber, - detail: `Heading at or below this level must have at least ${opts.minWords} word(s) in the title (found ${count}).`, - context: lines[h.lineNumber - 1], - }); + checkHeading(h, opts, lines, onError); } } diff --git a/markdownlint-rules/heading-numbering.js b/markdownlint-rules/heading-numbering.js index 10d6a09..09e1c8f 100644 --- a/markdownlint-rules/heading-numbering.js +++ b/markdownlint-rules/heading-numbering.js @@ -2,7 +2,11 @@ const { extractHeadings, + getNumberPrefixSpan, + insertTextForExpectedNumber, + isRuleSuppressedByComment, parseHeadingNumberPrefix, + pathMatchesAny, } = require("./utils.js"); /** @@ -66,8 +70,9 @@ function getSiblings(sorted, parentIndex, i) { /** * Expected number for heading at index i within its section (parent prefix + sibling sequence). - * Section-scoped: only used when at least one sibling has numbering. + * Used when section uses numbering (parent has numbering or at least one sibling has numbering). * 0-based when the first numbered sibling's last segment is "0" (e.g. "0", "0.0", "1.0"). + * When no sibling has numbering but parent does, returns parent prefix + "1" (first child). */ function getExpectedNumberInSection(sorted, parentIndex, i) { const h = sorted[i]; @@ -87,23 +92,33 @@ function getExpectedNumberInSection(sorted, parentIndex, i) { 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 lastSegment = firstNumbering.split(".").pop(); - const startAtZero = lastSegment === "0"; - const nextNum = startAtZero ? myIdx : myIdx + 1; + + let nextNum; + if (firstNumbering != null) { + const lastSegment = firstNumbering.split(".").pop(); + const startAtZero = lastSegment === "0"; + nextNum = startAtZero ? myIdx : myIdx + 1; + } else { + nextNum = 1; + } const prefix = parentNum ? parentNum + "." : ""; return prefix + String(nextNum); } /** - * Whether any sibling in the section (same parent) has numbering. + * Whether the section uses numbering: parent has numbering or any sibling (same parent, same level) has numbering. + * When true, all headings in the section must have numbering (parent's children must be numbered). */ function sectionUsesNumbering(sorted, parentIndex, i) { + const parentIdx = parentIndex[i]; + const parent = parentIdx != null ? sorted[parentIdx] : null; + if (parent != null && parseHeadingNumberPrefix(parent.rawText).numbering != null) { + return true; + } const siblings = getSiblings(sorted, parentIndex, i); return siblings.some( (s) => parseHeadingNumberPrefix(s.rawText).numbering != null @@ -111,19 +126,21 @@ function sectionUsesNumbering(sorted, parentIndex, i) { } /** - * First period style (hasH2Dot) among numbered siblings in this section; null if none numbered. + * First period style (hasH2Dot) among numbered siblings in this section; if none, use parent's style when parent has numbering; null otherwise. */ function getSectionPeriodStyle(sorted, parentIndex, i) { const siblings = getSiblings(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; + if (firstNumbered != null) { + return parseHeadingNumberPrefix(firstNumbered.rawText).hasH2Dot; } - /* c8 ignore stop */ - return parseHeadingNumberPrefix(firstNumbered.rawText).hasH2Dot; + const parentIdx = parentIndex[i]; + const parent = parentIdx != null ? sorted[parentIdx] : null; + if (parent == null) return null; + const parentParsed = parseHeadingNumberPrefix(parent.rawText); + return parentParsed.numbering != null ? parentParsed.hasH2Dot : null; } /** @@ -133,13 +150,16 @@ function getSectionPeriodStyle(sorted, parentIndex, i) { */ function getPeriodStyleError(ctx) { const { h, sorted, parentIndex, i, contextLine } = ctx; - const { hasH2Dot } = parseHeadingNumberPrefix(h.rawText); + const { numbering, hasH2Dot } = parseHeadingNumberPrefix(h.rawText); const sectionPeriodStyle = getSectionPeriodStyle(sorted, parentIndex, i); if (sectionPeriodStyle == null || hasH2Dot === sectionPeriodStyle) return null; + const insertText = numbering + (sectionPeriodStyle ? "." : "") + " "; + const { editColumn, deleteCount } = getNumberPrefixSpan(h.level, h.rawText, numbering, hasH2Dot); return { lineNumber: h.lineNumber, 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, + fixInfo: { editColumn, deleteCount, insertText }, }; } @@ -150,13 +170,22 @@ function getPeriodStyleError(ctx) { */ function checkSegmentCount(ctx) { const { h, sorted, parentIndex, i, contextLine } = ctx; - const { numbering } = parseHeadingNumberPrefix(h.rawText); + const { numbering, hasH2Dot } = parseHeadingNumberPrefix(h.rawText); if (numbering == null) return null; const rootLevel = getNumberingRootLevel(sorted, parentIndex, i); 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} segment(s) in number prefix "${numbering}"; expected ${expectedSegmentCount} (one per level from numbering root).`, context: contextLine }; + const expected = getExpectedNumberInSection(sorted, parentIndex, i); + const insertText = insertTextForExpectedNumber(expected, getSectionPeriodStyle(sorted, parentIndex, i)); + const { editColumn, deleteCount } = getNumberPrefixSpan(h.level, h.rawText, numbering, hasH2Dot); + const fixInfo = insertText ? { editColumn, deleteCount, insertText } : undefined; + 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, + ...(fixInfo && { fixInfo }), + }; } return null; } @@ -219,9 +248,16 @@ function addNumberingErrorsForNumberedHeading(h, i, ctx, errors) { const periodErr = getPeriodStyleError({ h, sorted, parentIndex, i, contextLine }); if (periodErr) errors.push(periodErr); const expected = getExpectedNumberInSection(sorted, parentIndex, i); - const num = parseHeadingNumberPrefix(h.rawText).numbering; + const { numbering: num, hasH2Dot } = parseHeadingNumberPrefix(h.rawText); if (expected != null && num !== expected) { - errors.push({ lineNumber: h.lineNumber, detail: `Number prefix "${num}" is out of sequence in this section; expected "${expected}" to match sibling order.`, context: contextLine }); + const insertText = insertTextForExpectedNumber(expected, getSectionPeriodStyle(sorted, parentIndex, i)); + const { editColumn, deleteCount } = getNumberPrefixSpan(h.level, h.rawText, num, hasH2Dot); + errors.push({ + lineNumber: h.lineNumber, + detail: `Number prefix "${num}" is out of sequence in this section; expected "${expected}" to match sibling order.`, + context: contextLine, + fixInfo: { editColumn, deleteCount, insertText }, + }); } } @@ -241,7 +277,16 @@ function getHeadingErrors(h, i, ctx) { const sectionUsesNum = sectionUsesNumbering(sorted, parentIndex, i); if (sectionUsesNum && numbering == null) { - 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 }); + const expected = getExpectedNumberInSection(sorted, parentIndex, i); + const insertText = insertTextForExpectedNumber(expected, getSectionPeriodStyle(sorted, parentIndex, i)); + const { editColumn, deleteCount } = getNumberPrefixSpan(h.level, h.rawText, null, false); + const fixInfo = insertText ? { editColumn, deleteCount, insertText } : undefined; + errors.push({ + lineNumber: h.lineNumber, + detail: "This heading has no number prefix but this section uses numbering (parent or siblings); add a number prefix to match (e.g. \"1.2\" for second under 1, \"1.2.1\" for first child under 1.2).", + context: contextLine, + ...(fixInfo && { fixInfo }), + }); return errors; } if (numbering == null) return errors; @@ -265,6 +310,29 @@ function readMaxSegmentValueOpts(block) { }; } +/** + * Get the expected number prefix for a new heading inserted at the given line and level. + * Uses the same section/sibling logic as the rule. Returns "" if the section does not use numbering. + * + * @param {string[]} lines - Document lines + * @param {number} insertAtLineNumber - 1-based line number where the new heading would be inserted + * @param {number} level - Heading level (1-6) of the new heading + * @returns {string} Prefix string (e.g. "1.2 " or "1.2. ") or "" + */ +function getExpectedPrefixForNewHeading(lines, insertAtLineNumber, level) { + const headings = extractHeadings(lines); + headings.push({ lineNumber: insertAtLineNumber, level, rawText: "" }); + const { sorted, parentIndex } = buildParentIndex(headings); + const synIndex = sorted.findIndex( + (h) => h.lineNumber === insertAtLineNumber && h.rawText === "" + ); + if (synIndex < 0) return ""; + if (!sectionUsesNumbering(sorted, parentIndex, synIndex)) return ""; + const expected = getExpectedNumberInSection(sorted, parentIndex, synIndex); + const usePeriod = getSectionPeriodStyle(sorted, parentIndex, synIndex); + return insertTextForExpectedNumber(expected, usePeriod); +} + /** * Normalize optional config for heading-numbering extensions: maxHeadingLevel, maxSegmentValue, level range for maxSegmentValue. * @@ -281,6 +349,30 @@ function getNumberingOpts(raw) { return opts; } +function shouldSkipByPath(filePath, block) { + const excludePatterns = block.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function getWithNumbering(headings) { + return headings + .map((h) => ({ ...h, parsed: parseHeadingNumberPrefix(h.rawText) })) + .filter((h) => h.parsed.numbering != null); +} + +function reportErrorsForHeading(h, index, ctx, onError) { + const contextLine = ctx.lines[h.lineNumber - 1]; + for (const err of getHeadingErrors(h, index, { + sorted: ctx.sorted, + parentIndex: ctx.parentIndex, + contextLine, + opts: ctx.opts, + })) { + if (isRuleSuppressedByComment(ctx.lines, err.lineNumber, "heading-numbering")) continue; + onError(err); + } +} + /** * markdownlint rule: validate numbered headings (segment count, sequence per section, period style). * Optional: maxHeadingLevel (disallow deeper headings), maxSegmentValue (cap segment value, with level range). @@ -289,30 +381,20 @@ function getNumberingOpts(raw) { * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { + const filePath = params.name || ""; + const block = params.config?.["heading-numbering"] ?? params.config ?? {}; + if (shouldSkipByPath(filePath, block)) return; + const lines = params.lines; const headings = extractHeadings(lines); const opts = getNumberingOpts(params.config || {}); - - const withNumbering = headings - .map((h) => ({ - ...h, - parsed: parseHeadingNumberPrefix(h.rawText), - })) - .filter((h) => h.parsed.numbering != null); - - const hasMaxHeadingLevel = typeof opts.maxHeadingLevel === "number"; - if (withNumbering.length === 0 && !hasMaxHeadingLevel) { - return; - } + const withNumbering = getWithNumbering(headings); + if (withNumbering.length === 0 && typeof opts.maxHeadingLevel !== "number") return; const { sorted, parentIndex } = buildParentIndex(headings); - + const ctx = { lines, sorted, parentIndex, opts }; for (let i = 0; i < sorted.length; i++) { - const h = sorted[i]; - const contextLine = lines[h.lineNumber - 1]; - for (const err of getHeadingErrors(h, i, { sorted, parentIndex, contextLine, opts })) { - onError(err); - } + reportErrorsForHeading(sorted[i], i, ctx, onError); } } @@ -322,4 +404,5 @@ module.exports = { "Numbered headings: segment count by numbering root; numbering consistent within each section; period style consistent within section.", tags: ["headings"], function: ruleFunction, + getExpectedPrefixForNewHeading, }; diff --git a/markdownlint-rules/heading-title-case.js b/markdownlint-rules/heading-title-case.js index e09dccb..4fcf380 100644 --- a/markdownlint-rules/heading-title-case.js +++ b/markdownlint-rules/heading-title-case.js @@ -2,10 +2,24 @@ const { extractHeadings, + isRuleSuppressedByComment, parseHeadingNumberPrefix, + pathMatchesAny, stripInlineCode, } = require("./utils.js"); +/** Token looks like a file name (e.g. README.md, package.json, Makefile); suggest backticks instead of case change. */ +const RE_FILE_LIKE = /^[^\s`]*[A-Za-z][^\s`]*\.[A-Za-z0-9]+$/; +const FILENAME_NO_EXT = new Set(["makefile", "dockerfile", "dockerignore", "gitignore", "gitattributes", "npmrc", "editorconfig"]); + +function looksLikeFileName(token) { + if (!token || typeof token !== "string") return false; + const t = token.trim(); + if (RE_FILE_LIKE.test(t)) return true; + const lower = t.toLowerCase(); + return FILENAME_NO_EXT.has(lower) || FILENAME_NO_EXT.has(lower.replace(/^\./, "")); +} + /** Default lowercase words for AP-style headings (unless first/last/subphrase-start). */ const DEFAULT_LOWERCASE_WORDS = new Set([ // Articles @@ -24,6 +38,12 @@ const DEFAULT_LOWERCASE_WORDS = new Set([ "v", "vs", ]); +/** Words that commonly precede a single-letter label (e.g. Phase A, Step B, Appendix A, Type A). */ +const LABEL_PARENT_WORDS = new Set([ + "appendix", "category", "chapter", "class", "grade", "item", "letter", "level", "module", + "option", "part", "phase", "section", "stage", "step", "tier", "type", "unit", "version", +]); + /** * Strip leading/trailing punctuation from a word for comparison (e.g. "word," -> "word"). * @param {string} w @@ -57,13 +77,13 @@ function isAllLower(w) { } /** - * True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound start). - * @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean }} opts + * True if this position is exempt from lowercase (first/last/subphrase start/hyphen compound start/single-letter label). + * @param {{ isFirst: boolean, isLast: boolean, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts * @returns {boolean} */ function isExemptFromLowercase(opts) { - const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart } = opts; - return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart); + const { isFirst, isLast, isSubphraseStart, isHyphenCompoundStart, isPhaseLabel } = opts; + return Boolean(isFirst || isLast || isSubphraseStart || isHyphenCompoundStart || isPhaseLabel); } /** @@ -80,7 +100,7 @@ function getCapitalizationKind(opts) { /** * Validate one word for title case; returns error detail string or null if valid. - * @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean }} opts + * @param {{ raw: string, core: string, isFirst: boolean, isLast: boolean, lowercaseWords: Set, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts * @returns {string|null} */ function checkWord(opts) { @@ -94,6 +114,26 @@ function checkWord(opts) { return `Word "${core}" should be capitalized (${getCapitalizationKind(opts)} word in title case).`; } +/** + * Return the corrected segment text for fixInfo: lowercase or capitalize per AP rules, preserving punctuation. + * @param {string} rawSeg - Segment as in source (e.g. "And", "(in", "practice)") + * @param {string} core - stripWordPunctuation(rawSeg) + * @param {{ isFirst: boolean, isLast: boolean, lowercaseWords: Set, isSubphraseStart?: boolean, isHyphenCompoundStart?: boolean, isPhaseLabel?: boolean }} opts + * @returns {string} + */ +function getCorrectedSegment(rawSeg, core, opts) { + const coreLower = core.toLowerCase(); + const shouldBeLower = !isExemptFromLowercase(opts) && opts.lowercaseWords.has(coreLower); + const correctedCore = shouldBeLower + ? coreLower + : core.charAt(0).toUpperCase() + core.slice(1).toLowerCase(); + const firstAlpha = rawSeg.search(/[A-Za-z0-9]/); + if (firstAlpha < 0) return rawSeg; + const runMatch = rawSeg.slice(firstAlpha).match(/^[A-Za-z0-9]+/); + const runLen = runMatch ? runMatch[0].length : 0; + return rawSeg.slice(0, firstAlpha) + correctedCore + rawSeg.slice(firstAlpha + runLen); +} + /** * Compute 0-based offset and length of segment j within a hyphenated word. * @param {string[]} rawSegments - Parts of the word split on '-' @@ -107,13 +147,24 @@ function getSegmentPosition(rawSegments, j) { return { segmentOffset, segmentLength: rawSegments[j].length }; } +/** + * True when the segment is a single letter immediately after a label-parent word (e.g. Phase A, Step B, Appendix A). + * @param {number} j - Segment index (must be 0 for first segment of word) + * @param {string} core - stripWordPunctuation(segment) + * @param {string} previousWordCore - Previous word core, lowercased + * @returns {boolean} + */ +function isPhaseLabelSegment(j, core, previousWordCore) { + return j === 0 && core.length === 1 && /^[A-Za-z]$/.test(core) && LABEL_PARENT_WORDS.has(previousWordCore); +} + /** * 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 + * @param {{ words: string[], rawSegments: string[], i: number, j: number, wordIsSubphraseStart: boolean, lowercaseWords: Set, previousWordCore?: string }} 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 { words, rawSegments, i, j, wordIsSubphraseStart, lowercaseWords, previousWordCore } = opts; const rawSeg = rawSegments[j]; const core = stripWordPunctuation(rawSeg); if (!core || !/[a-zA-Z]/.test(core)) return null; @@ -122,6 +173,7 @@ function checkOneSegment(opts) { const isLast = i === words.length - 1 && j === rawSegments.length - 1; const isSubphraseStart = j === 0 && wordIsSubphraseStart; const isHyphenCompoundStart = rawSegments.length > 1 && j === 0; + const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore); const detail = checkWord({ raw: rawSeg, @@ -131,14 +183,24 @@ function checkOneSegment(opts) { lowercaseWords, isSubphraseStart, isHyphenCompoundStart, + isPhaseLabel, }); if (!detail) return null; + const insertText = getCorrectedSegment(rawSeg, core, { + isFirst, + isLast, + lowercaseWords, + isSubphraseStart, + isHyphenCompoundStart, + isPhaseLabel, + }); const seg = getSegmentPosition(rawSegments, j); return { valid: false, detail, wordIndex: i, + insertText, ...(seg && { segmentOffset: seg.segmentOffset, segmentLength: seg.segmentLength }), }; } @@ -147,22 +209,25 @@ function checkOneSegment(opts) { * Validate title case on a heading's title part (numbering stripped). * AP rules: first/last/subphrase-start capitalized; hyphenated segments checked separately; * first word after colon treated as subphrase start. Words in backticks are excluded. + * Returns all violations so each can be reported and highlighted separately. * * @param {string} titleText - Title after stripping numbering * @param {Set} lowercaseWords - Words that must be lowercase in middle - * @returns {{ valid: boolean, detail?: string, wordIndex?: number, segmentOffset?: number, segmentLength?: number }} + * @returns {{ valid: boolean, errors: Array<{ detail: string, wordIndex: number, segmentOffset?: number, segmentLength?: number }> }} */ function checkTitleCase(titleText, lowercaseWords) { const withCodeStripped = stripInlineCode(titleText); const words = withCodeStripped.split(/\s+/).filter((w) => w.length > 0); - if (words.length === 0) return { valid: true }; + if (words.length === 0) return { valid: true, errors: [] }; + const errors = []; for (let i = 0; i < words.length; i++) { const raw = words[i]; const firstAlphaIdx = raw.search(/[A-Za-z0-9]/); const prefix = firstAlphaIdx > 0 ? raw.slice(0, firstAlphaIdx) : ""; const afterColon = i > 0 && words[i - 1].replace(/\s+$/, "").endsWith(":"); const wordIsSubphraseStart = prefix.includes("(") || prefix.includes("[") || afterColon; + const previousWordCore = i > 0 ? stripWordPunctuation(words[i - 1]).toLowerCase() : ""; const rawSegments = raw.split(/-/); for (let j = 0; j < rawSegments.length; j++) { @@ -173,15 +238,116 @@ function checkTitleCase(titleText, lowercaseWords) { j, wordIsSubphraseStart, lowercaseWords, + previousWordCore, }); - if (result) return result; + if (result) errors.push({ detail: result.detail, wordIndex: result.wordIndex, insertText: result.insertText, segmentOffset: result.segmentOffset, segmentLength: result.segmentLength }); + } + } + return { valid: errors.length === 0, errors }; +} + +/** + * Build lowercase-words Set from applyTitleCase options. + * @param {{ lowercaseWords?: string[]|Set, lowercaseWordsReplaceDefault?: boolean }} options + * @returns {Set} + */ +function getLowercaseWordsFromOptions(options) { + const customLower = options.lowercaseWords; + const configSet = Array.isArray(customLower) + ? new Set([...customLower].map((w) => String(w).toLowerCase().trim()).filter(Boolean)) + : customLower instanceof Set + ? customLower + : new Set(); + const replaceDefault = options.lowercaseWordsReplaceDefault === true; + return replaceDefault + ? configSet + : new Set([...DEFAULT_LOWERCASE_WORDS, ...configSet]); +} + +/** + * Correct one segment of a word for AP title case. + * @param {string} rawSeg - Segment as in source + * @param {string} core - stripWordPunctuation(rawSeg) + * @param {{ i: number, j: number, words: string[], rawSegments: string[], wordIsSubphraseStart: boolean, lowercaseWords: Set, previousWordCore?: string }} ctx + * @returns {string} + */ +function correctSegmentForTitleCase(rawSeg, core, ctx) { + const { i, j, words, rawSegments, wordIsSubphraseStart, lowercaseWords, previousWordCore } = ctx; + const isFirst = i === 0 && j === 0; + const isLast = i === words.length - 1 && j === rawSegments.length - 1; + const isSubphraseStart = j === 0 && wordIsSubphraseStart; + const isHyphenCompoundStart = rawSegments.length > 1 && j === 0; + const isPhaseLabel = isPhaseLabelSegment(j, core, previousWordCore); + return getCorrectedSegment(rawSeg, core, { + isFirst, + isLast, + lowercaseWords, + isSubphraseStart, + isHyphenCompoundStart, + isPhaseLabel, + }); +} + +/** + * Process one word (possibly hyphenated) for applyTitleCase. + * @param {string} raw - Raw word + * @param {number} i - Word index + * @param {string[]} words - All words + * @param {Set} lowercaseWords + * @returns {string} + */ +function processWordForTitleCase(raw, i, words, lowercaseWords) { + const firstAlphaIdx = raw.search(/[A-Za-z0-9]/); + const prefix = firstAlphaIdx > 0 ? raw.slice(0, firstAlphaIdx) : ""; + const afterColon = i > 0 && words[i - 1].replace(/\s+$/, "").endsWith(":"); + const wordIsSubphraseStart = prefix.includes("(") || prefix.includes("[") || afterColon; + const previousWordCore = i > 0 ? stripWordPunctuation(words[i - 1]).toLowerCase() : ""; + const rawSegments = raw.split(/-/); + const segmentParts = []; + for (let j = 0; j < rawSegments.length; j++) { + const rawSeg = rawSegments[j]; + const core = stripWordPunctuation(rawSeg); + if (!core || !/[a-zA-Z]/.test(core)) { + segmentParts.push(rawSeg); + continue; } + segmentParts.push(correctSegmentForTitleCase(rawSeg, core, { + i, + j, + words, + rawSegments, + wordIsSubphraseStart, + lowercaseWords, + previousWordCore, + })); } - return { valid: true }; + return segmentParts.join("-"); +} + +/** + * Apply AP title case to a title string and return the full corrected title. + * Uses the same word/segment logic and getCorrectedSegment as the rule. + * + * @param {string} titleText - Raw title (e.g. "the quick Brown" or "getting started") + * @param {{ lowercaseWords?: string[]|Set, lowercaseWordsReplaceDefault?: boolean }} [options] - Optional config; defaults to DEFAULT_LOWERCASE_WORDS when not provided + * @returns {string} Title with AP capitalization applied + */ +function applyTitleCase(titleText, options = {}) { + const lowercaseWords = getLowercaseWordsFromOptions(options); + const withCodeStripped = stripInlineCode(titleText); + const words = withCodeStripped.split(/\s+/).filter((w) => w.length > 0); + if (words.length === 0) return titleText; + const resultParts = words.map((raw, i) => + processWordForTitleCase(raw, i, words, lowercaseWords) + ); + return resultParts.join(" "); } /** * Get 1-based column and length of the i-th word (or segment within it) in the heading line. + * Uses the same tokenization as checkTitleCase (stripInlineCode then \S+) so word indices + * align when the heading contains inline code (backticks) or parentheses. + * * @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") @@ -190,7 +356,8 @@ function checkTitleCase(titleText, lowercaseWords) { */ function getWordRangeInLine(line, rawText, titleText, opts) { const { wordIndex, segmentOffset, segmentLength } = opts; - const wordMatches = [...titleText.matchAll(/\S+/g)]; + const withCodeStripped = stripInlineCode(titleText); + const wordMatches = [...withCodeStripped.matchAll(/\S+/g)]; if (wordIndex < 0 || wordIndex >= wordMatches.length) return null; const rawTextStart = line.indexOf(rawText); if (rawTextStart === -1) return null; @@ -206,6 +373,103 @@ function getWordRangeInLine(line, rawText, titleText, opts) { return { column, length }; } +/** + * Report a single heading-title-case error (with optional range and fixInfo). + * @param {object} opts + * @param {function(object): void} opts.onError + * @param {number} opts.lineNumber + * @param {string} opts.line + * @param {string} opts.detail + * @param {{ column: number, length: number }|null} opts.rangeInfo + * @param {string|undefined} opts.insertText + */ +function reportTitleCaseError(opts) { + const { onError, lineNumber, line, detail, rangeInfo, insertText, lines } = opts; + if (lines && isRuleSuppressedByComment(lines, lineNumber, "heading-title-case")) return; + onError({ + lineNumber, + detail, + context: line, + ...(rangeInfo && { range: [rangeInfo.column, rangeInfo.length] }), + ...(rangeInfo && insertText != null && { + fixInfo: { editColumn: rangeInfo.column, deleteCount: rangeInfo.length, insertText }, + }), + }); +} + +function shouldSkipByPath(filePath, options) { + const excludePatterns = options.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function getLowercaseWords(options) { + const customLower = options.lowercaseWords; + const configSet = Array.isArray(customLower) + ? new Set(customLower.map((w) => String(w).toLowerCase().trim()).filter(Boolean)) + : new Set(); + const replaceDefault = options.lowercaseWordsReplaceDefault === true; + return replaceDefault ? configSet : new Set([...DEFAULT_LOWERCASE_WORDS, ...configSet]); +} + +/** + * Return { core, prefix, suffix } so that raw === prefix + core + suffix and core has no leading/trailing punctuation. + * @param {string} raw - Word token (e.g. "(utils.js," or "allow-custom-anchors.js)") + * @returns {{ core: string, prefix: string, suffix: string }} + */ +function splitWordPunctuation(raw) { + const core = stripWordPunctuation(raw); + const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0] ?? ""; + const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0] ?? ""; + return { core, prefix: leading, suffix: trailing }; +} + +function reportFilenameErrors(opts) { + const { h, line, titleText, onError, lines } = opts; + const words = stripInlineCode(titleText).split(/\s+/).filter((w) => w.length > 0); + const fileNameWordIndices = new Set(); + for (let wi = 0; wi < words.length; wi++) { + const raw = words[wi]; + const { core, prefix, suffix } = splitWordPunctuation(raw); + if (!core || !looksLikeFileName(core)) continue; + const rangeInfo = getWordRangeInLine(line, h.rawText, titleText, { wordIndex: wi }); + /* c8 ignore next 1 -- defensive: titleText is always substring of rawText from parseHeadingNumberPrefix */ + if (!rangeInfo) continue; + fileNameWordIndices.add(wi); + const insertText = prefix + "`" + core + "`" + suffix; + reportTitleCaseError({ + onError, + lineNumber: h.lineNumber, + line, + detail: `File name "${core}" should be enclosed in backticks.`, + rangeInfo, + insertText, + lines, + }); + } + return fileNameWordIndices; +} + +function reportTitleCaseErrors(opts) { + const { h, line, titleText, result, fileNameWordIndices, onError, lines } = opts; + for (const err of result.errors) { + if (fileNameWordIndices.has(err.wordIndex)) continue; + const rangeInfo = getWordRangeInLine(line, h.rawText, titleText, { + wordIndex: err.wordIndex, + segmentOffset: err.segmentOffset, + segmentLength: err.segmentLength, + }); + reportTitleCaseError({ + onError, + lineNumber: h.lineNumber, + line, + detail: err.detail, + rangeInfo, + insertText: err.insertText, + lines, + }); + } +} + /** * markdownlint rule: enforce AP-style heading capitalization. * - First and last words must be capitalized. @@ -217,40 +481,29 @@ function getWordRangeInLine(line, rawText, titleText, opts) { * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { - const options = params.config?.["heading-title-case"] ?? {}; - const customLower = options.lowercaseWords; - const configSet = Array.isArray(customLower) - ? new Set(customLower.map((w) => String(w).toLowerCase().trim()).filter(Boolean)) - : new Set(); - const replaceDefault = options.lowercaseWordsReplaceDefault === true; - const lowercaseWords = replaceDefault - ? configSet - : new Set([...DEFAULT_LOWERCASE_WORDS, ...configSet]); + const filePath = params.name || ""; + const options = params.config?.["heading-title-case"] ?? params.config ?? {}; + if (shouldSkipByPath(filePath, options)) return; + + const lowercaseWords = getLowercaseWords(options); + const headings = extractHeadings(params.lines); - const headings = extractHeadings(params.lines); - for (const h of headings) { - 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: line, - ...(rangeInfo && { range: [rangeInfo.column, rangeInfo.length] }), - }); - } + const lines = params.lines; + for (const h of headings) { + const { titleText } = parseHeadingNumberPrefix(h.rawText); + const line = lines[h.lineNumber - 1]; + const fileNameWordIndices = reportFilenameErrors({ h, line, titleText, onError, lines }); + const result = checkTitleCase(titleText, lowercaseWords); + if (!result.valid) { + reportTitleCaseErrors({ h, line, titleText, result, fileNameWordIndices, onError, lines }); } } +} module.exports = { names: ["heading-title-case"], description: "Enforce AP-style capitalization for headings, with exceptions for words in backticks and configurable lowercase words.", tags: ["headings"], function: ruleFunction, + applyTitleCase, }; diff --git a/markdownlint-rules/no-duplicate-headings-normalized.js b/markdownlint-rules/no-duplicate-headings-normalized.js index 5be55a4..708cbb9 100644 --- a/markdownlint-rules/no-duplicate-headings-normalized.js +++ b/markdownlint-rules/no-duplicate-headings-normalized.js @@ -2,9 +2,44 @@ const { extractHeadings, + isRuleSuppressedByComment, normalizedTitleForDuplicate, + pathMatchesAny, } = require("./utils.js"); +function shouldSkipByPath(filePath, block) { + const excludePatterns = block.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function buildByNormalizedMap(headings) { + const byNormalized = new Map(); + for (const h of headings) { + const key = normalizedTitleForDuplicate(h.rawText); + if (!key) continue; + if (!byNormalized.has(key)) byNormalized.set(key, []); + byNormalized.get(key).push(h); + } + return byNormalized; +} + +function reportDuplicateGroups(byNormalized, lines, onError) { + for (const [normTitle, group] of byNormalized) { + if (group.length <= 1) continue; + group.sort((a, b) => a.lineNumber - b.lineNumber); + const first = group[0]; + for (let i = 1; i < group.length; i++) { + const dup = group[i]; + if (isRuleSuppressedByComment(lines, dup.lineNumber, "no-duplicate-headings-normalized")) continue; + onError({ + lineNumber: dup.lineNumber, + 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: lines[dup.lineNumber - 1], + }); + } + } +} + /** * markdownlint rule: disallow duplicate heading titles after stripping numbering * and normalizing (trim, collapse whitespace, lowercase). Reports each duplicate @@ -14,36 +49,14 @@ const { * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { - const headings = extractHeadings(params.lines); - const byNormalized = new Map(); - - for (const h of headings) { - const key = normalizedTitleForDuplicate(h.rawText); - if (!key) { - continue; - } - if (!byNormalized.has(key)) { - byNormalized.set(key, []); - } - byNormalized.get(key).push(h); - } + const filePath = params.name || ""; + const block = params.config?.["no-duplicate-headings-normalized"] ?? params.config ?? {}; + if (shouldSkipByPath(filePath, block)) return; - for (const [normTitle, group] of byNormalized) { - if (group.length <= 1) { - continue; - } - group.sort((a, b) => a.lineNumber - b.lineNumber); - const first = group[0]; - for (let i = 1; i < group.length; i++) { - const dup = group[i]; - onError({ - lineNumber: dup.lineNumber, - 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], - }); - } - } - } + const headings = extractHeadings(params.lines); + const byNormalized = buildByNormalizedMap(headings); + reportDuplicateGroups(byNormalized, params.lines, onError); +} module.exports = { names: ["no-duplicate-headings-normalized"], diff --git a/markdownlint-rules/no-empty-heading.js b/markdownlint-rules/no-empty-heading.js index c31a228..bd57f6d 100644 --- a/markdownlint-rules/no-empty-heading.js +++ b/markdownlint-rules/no-empty-heading.js @@ -1,10 +1,11 @@ "use strict"; -const { extractHeadings, pathMatchesAny } = require("./utils.js"); +const { extractHeadings, isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js"); /** Match HTML comment line (single line). */ const RE_HTML_COMMENT = /^\s*\s*$/; + /** Match the exact suppress comment: (optional whitespace). */ const RE_SUPPRESS_COMMENT_RAW = /^\s*\s*$/; @@ -45,23 +46,49 @@ function isHtmlTagLine(trimmed) { return RE_HTML_TAG_LINE.test(trimmed); } +/** + * Return true if the trimmed line is inside or part of a multi-line HTML comment (). + * Used to treat such lines like single-line HTML comments for content counting. + * + * @param {string} trimmed - Trimmed line + * @param {{ inMultilineComment: boolean }} state - Current multi-line comment state + * @returns {{ isCommentLine: boolean, inMultilineComment: boolean }} - Whether this line counts as comment; new state for next line + */ +function updateMultilineCommentState(trimmed, state) { + const wasIn = state.inMultilineComment; + if (wasIn) { + const closes = /-->\s*$/.test(trimmed); + return { isCommentLine: true, inMultilineComment: !closes }; + } + if (RE_HTML_COMMENT.test(trimmed)) { + return { isCommentLine: true, inMultilineComment: false }; + } + const opens = /^\s*\s*$/.test(trimmed); + return { isCommentLine: opens, inMultilineComment: opens }; +} + /** * Return true if the trimmed line counts as content under the given config. * Prose (non-blank, not only HTML comment/tag) always counts; blank, HTML-comment, * and HTML-tag lines count only when the corresponding config option is true. + * Single-line and multi-line HTML comments are treated alike. * The suppress comment never counts as content (it only suppresses the rule for that section). * * @param {string} trimmed - Trimmed line * @param {{ countBlankLinesAsContent: boolean, countHTMLCommentsAsContent: boolean, countHtmlLinesAsContent: boolean }} opts + * @param {{ isCommentLine: boolean }} multilineContext - Optional; when isCommentLine is true, line is treated as HTML comment * @returns {boolean} */ -function isContentLine(trimmed, opts) { +function isContentLine(trimmed, opts, multilineContext) { if (trimmed === "") { return opts.countBlankLinesAsContent; } if (isSuppressComment(trimmed)) { return false; } + if (multilineContext && multilineContext.isCommentLine) { + return opts.countHTMLCommentsAsContent; + } if (RE_HTML_COMMENT.test(trimmed)) { return opts.countHTMLCommentsAsContent; } @@ -122,16 +149,22 @@ function sectionContentLineCount(ctx) { const { lines, heading, endLine, headingLineNumbers, contentOpts } = ctx; let count = 0; let fenceState = { inFence: false, fenceMarker: null }; + let multilineCommentState = { inMultilineComment: false }; const lastLine = Math.min(endLine, lines.length); for (let lineNumber = heading.lineNumber + 1; lineNumber <= lastLine; lineNumber++) { if (headingLineNumbers.has(lineNumber)) break; const trimmed = lines[lineNumber - 1].trim(); + const { isCommentLine, inMultilineComment } = updateMultilineCommentState( + trimmed, + multilineCommentState + ); + multilineCommentState = { inMultilineComment }; const nextFence = updateFenceState(trimmed, fenceState); fenceState = nextFence; if (!contentOpts.countCodeBlockLinesAsContent && (nextFence.isFenceLine || nextFence.inFence)) { continue; } - if (isContentLine(trimmed, contentOpts)) count += 1; + if (isContentLine(trimmed, contentOpts, { isCommentLine })) count += 1; } return count; } @@ -210,6 +243,40 @@ function buildDetailMessage(minimumContentLines, contentOpts) { return `H2+ heading must have ${minDesc} directly under it before any subheading (${whatCounts}).`; } +/** + * Return true if this H2+ heading has too little content (violation). + * + * @param {object} ctx - { heading, headings, lines, headingLineNumbers, minimumContentLines, contentOpts } + * @returns {boolean} + */ +function headingHasTooLittleContent(ctx) { + const { heading, headings, lines, headingLineNumbers, minimumContentLines, contentOpts } = ctx; + const nextSameOrHigher = headings.find( + (h) => h.lineNumber > heading.lineNumber && h.level <= heading.level + ); + const endLine = nextSameOrHigher ? nextSameOrHigher.lineNumber - 1 : lines.length; + if (sectionHasSuppressComment(lines, heading, endLine, headingLineNumbers)) { + return false; + } + const count = sectionContentLineCount({ lines, heading, endLine, headingLineNumbers, contentOpts }); + return count < minimumContentLines; +} + +function reportEmptyHeading(heading, ctx) { + const { headings, lines, headingLineNumbers, minimumContentLines, contentOpts, onError } = ctx; + if (!headingHasTooLittleContent({ + heading, headings, lines, headingLineNumbers, minimumContentLines, contentOpts, + })) { + return; + } + if (isRuleSuppressedByComment(lines, heading.lineNumber, "no-empty-heading")) return; + onError({ + lineNumber: heading.lineNumber, + detail: buildDetailMessage(minimumContentLines, contentOpts), + context: lines[heading.lineNumber - 1], + }); +} + /** * markdownlint rule: every H2+ heading must have at least one line of content * directly under it (before any subheading). Content under subheadings does not @@ -222,36 +289,20 @@ function buildDetailMessage(minimumContentLines, contentOpts) { function ruleFunction(params, onError) { const lines = params.lines; const filePath = params.name || ""; - const config = params.config || {}; - const excludePatterns = config.excludePathPatterns; + const block = params.config?.["no-empty-heading"] ?? params.config ?? {}; + const excludePatterns = block.excludePathPatterns; if (Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns)) { return; } - const { minimumContentLines, contentOpts } = normalizeConfig(config); + const { minimumContentLines, contentOpts } = normalizeConfig(block); const headings = extractHeadings(lines); const h2Plus = headings.filter((h) => h.level >= 2); const headingLineNumbers = new Set(headings.map((h) => h.lineNumber)); + const ctx = { headings, lines, headingLineNumbers, minimumContentLines, contentOpts, onError }; for (const heading of h2Plus) { - const nextSameOrHigher = headings.find( - (h) => h.lineNumber > heading.lineNumber && h.level <= heading.level - ); - const endLine = nextSameOrHigher ? nextSameOrHigher.lineNumber - 1 : lines.length; - - if (sectionHasSuppressComment(lines, heading, endLine, headingLineNumbers)) { - continue; - } - const count = sectionContentLineCount({ lines, heading, endLine, headingLineNumbers, contentOpts }); - if (count >= minimumContentLines) { - continue; - } - - onError({ - lineNumber: heading.lineNumber, - detail: buildDetailMessage(minimumContentLines, contentOpts), - context: lines[heading.lineNumber - 1], - }); + reportEmptyHeading(heading, ctx); } } diff --git a/markdownlint-rules/no-h1-content.js b/markdownlint-rules/no-h1-content.js index 8d6f9a7..ddd4395 100644 --- a/markdownlint-rules/no-h1-content.js +++ b/markdownlint-rules/no-h1-content.js @@ -1,6 +1,6 @@ "use strict"; -const { extractHeadings, pathMatchesAny } = require("./utils.js"); +const { extractHeadings, isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js"); /** Match HTML comment line (single line). */ const RE_HTML_COMMENT = /^\s*\s*$/; @@ -8,19 +8,43 @@ const RE_HTML_COMMENT = /^\s*\s*$/; /** Match list item that is a single anchor link: - [text](#id) or 1. [text](#id). */ const RE_TOC_LIST_ITEM = /^\s*([-*]|\d+\.)\s+\[.+\]\(#\S+\)\s*$/; -/** Match badge line(s): [![alt](img-url)](link-url), optionally repeated with spaces. */ -const RE_BADGE_LINE = /^\s*(\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*)+\s*$/; +/** Match badge line(s): [![alt](img-url)](link-url) or [![alt][img-ref]][link-ref], optionally repeated. */ +const RE_BADGE_LINE = /^\s*(\[!\[[^\]]*\](?:\([^)]*\)|\[[^\]]*\])\](?:\([^)]*\)|\[[^\]]*\])\s*)+\s*$/; + +/** + * Update multi-line HTML comment state and return whether this line is part of a comment. + * + * @param {string} trimmed - Trimmed line + * @param {{ inMultilineComment: boolean }} state - Current state + * @returns {{ allowed: boolean, inMultilineComment: boolean }} + */ +function updateMultilineCommentState(trimmed, state) { + if (state.inMultilineComment) { + const closes = /-->\s*$/.test(trimmed); + return { allowed: true, inMultilineComment: !closes }; + } + if (RE_HTML_COMMENT.test(trimmed)) { + return { allowed: true, inMultilineComment: false }; + } + const opens = /^\s*\s*$/.test(trimmed); + return { allowed: opens, inMultilineComment: opens }; +} /** * Return true if the trimmed line is allowed under h1 (blank, TOC, badge, or HTML comment). + * Single-line and multi-line HTML comments are allowed. * * @param {string} trimmed - Trimmed line + * @param {{ isCommentLine: boolean }} multilineContext - When isCommentLine is true, line is part of multi-line comment * @returns {boolean} */ -function isAllowedUnderH1(trimmed) { +function isAllowedUnderH1(trimmed, multilineContext) { if (trimmed === "") { return true; } + if (multilineContext && multilineContext.isCommentLine) { + return true; + } if (RE_HTML_COMMENT.test(trimmed)) { return true; } @@ -33,6 +57,19 @@ function isAllowedUnderH1(trimmed) { return false; } +function shouldSkipByPath(filePath, block) { + const excludePatterns = block.excludePathPatterns; + return Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns); +} + +function getH1BlockRange(headings, lines) { + const firstH1 = headings.find((h) => h.level === 1); + if (!firstH1) return null; + const nextHeading = headings.find((h) => h.lineNumber > firstH1.lineNumber); + const endLine = nextHeading ? nextHeading.lineNumber - 1 : lines.length; + return { startLine: firstH1.lineNumber + 1, endLine }; +} + /** * markdownlint rule: under the first h1 heading, only table-of-contents content * is allowed (blank lines, list items that are anchor links, badges, HTML comments). @@ -44,28 +81,25 @@ function isAllowedUnderH1(trimmed) { function ruleFunction(params, onError) { const lines = params.lines; const filePath = params.name || ""; - const config = params.config || {}; - const excludePatterns = config.excludePathPatterns; - if (Array.isArray(excludePatterns) && excludePatterns.length > 0 && pathMatchesAny(filePath, excludePatterns)) { - return; - } + const block = params.config?.["no-h1-content"] ?? params.config ?? {}; + if (shouldSkipByPath(filePath, block)) return; const headings = extractHeadings(lines); - const firstH1 = headings.find((h) => h.level === 1); - if (!firstH1) { - return; - } - - const nextHeading = headings.find((h) => h.lineNumber > firstH1.lineNumber); - const endLine = nextHeading ? nextHeading.lineNumber - 1 : lines.length; + const range = getH1BlockRange(headings, lines); + if (!range) return; - for (let lineNumber = firstH1.lineNumber + 1; lineNumber <= endLine; lineNumber++) { + let multilineCommentState = { inMultilineComment: false }; + for (let lineNumber = range.startLine; lineNumber <= range.endLine; lineNumber++) { const line = lines[lineNumber - 1]; const trimmed = line.trim(); + const { allowed: isCommentLine, inMultilineComment } = updateMultilineCommentState( + trimmed, + multilineCommentState + ); + multilineCommentState = { inMultilineComment }; - if (isAllowedUnderH1(trimmed)) { - continue; - } + if (isAllowedUnderH1(trimmed, { isCommentLine })) continue; + if (isRuleSuppressedByComment(lines, lineNumber, "no-h1-content")) continue; onError({ lineNumber, diff --git a/markdownlint-rules/no-heading-like-lines.js b/markdownlint-rules/no-heading-like-lines.js index f445d8c..d1a6978 100644 --- a/markdownlint-rules/no-heading-like-lines.js +++ b/markdownlint-rules/no-heading-like-lines.js @@ -1,42 +1,220 @@ "use strict"; +const path = require("path"); +const { extractHeadings, isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js"); + +/** True if heading-title-case.js was successfully required (present in same rules dir). */ +let hasHeadingTitleCase = false; +/** True if heading-numbering.js was successfully required (present in same rules dir). */ +let hasHeadingNumbering = false; +/** applyTitleCase from heading-title-case (only valid when hasHeadingTitleCase). */ +let applyTitleCaseFn = null; +/** getExpectedPrefixForNewHeading from heading-numbering (only valid when hasHeadingNumbering). */ +let getExpectedPrefixForNewHeadingFn = null; + +/* c8 ignore start -- optional deps: load at most once; catch branches hard to cover in same process */ +try { + const titleCaseMod = require(path.join(__dirname, "heading-title-case.js")); + applyTitleCaseFn = titleCaseMod.applyTitleCase; + hasHeadingTitleCase = true; +} catch { + // Optional: user may not copy heading-title-case.js +} +try { + const numberingMod = require(path.join(__dirname, "heading-numbering.js")); + getExpectedPrefixForNewHeadingFn = numberingMod.getExpectedPrefixForNewHeading; + hasHeadingNumbering = true; +} catch { + // Optional: user may not copy heading-numbering.js +} +/* c8 ignore stop */ + +/** Patterns: [regex, description]. Order matches EXTRACTORS. Includes MD036-style whole-line emphasis. Require content (.+ not .*) so **:** does not match. */ +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*)"], + [/^\s*\*\*(.+)\*\*\s*$/, "bold only (whole line, **Text**)"], + [/^\s*\*([^*]+)\*\s*$/, "italic only (whole line, *Text*)"], +]; + +/** For each pattern index: [regex, formatter(match) -> title]. */ +const EXTRACTORS = [ + [/^\s*\*\*(.+):\*\*\s*$/, (m) => m[1] + ":"], + [/^\s*\*\*(.+)\*\*:\s*$/, (m) => m[1] + ":"], + [/^\s*[0-9]+\.\s+\*\*(.+)\*\*\s*$/, (m) => m[1].trim()], + [/^\s*\*(.+):\*\s*$/, (m) => m[1] + ":"], + [/^\s*\*(.+)\*:\s*$/, (m) => m[1] + ":"], + [/^\s*[0-9]+\.\s+\*(.+)\*\s*$/, (m) => m[1].trim()], + [/^\s*\*\*(.+)\*\*\s*$/, (m) => m[1].trim()], + [/^\s*\*([^*]+)\*\s*$/, (m) => m[1].trim()], +]; + +/** Pattern indices that are MD036-style (whole-line emphasis); skip when content ends with punctuation. */ +const MD036_STYLE_PATTERN_INDICES = new Set([6, 7]); + +/** + * Extract plain title from a heading-like line given the pattern index that matched. + * @param {string} trimmedLine - Trimmed line content + * @param {number} patternIndex - Index into PATTERNS (0-7) + * @returns {string} Extracted title (e.g. "Summary:" or "Introduction") + */ +function extractTitleFromHeadingLike(trimmedLine, patternIndex) { + const entry = EXTRACTORS[patternIndex]; + /* c8 ignore next 1 -- patternIndex always 0-7 from PATTERNS loop */ + if (!entry) return trimmedLine; + const [regex, format] = entry; + const m = trimmedLine.match(regex); + return m ? format(m) : trimmedLine; +} + +/** + * Get context-aware heading level for a violation at lineNumber when convertToHeading is true. + * @param {{ lineNumber: number, level: number, rawText: string }[]} headings - From extractHeadings(lines) + * @param {number} violationLineNumber - 1-based line of the heading-like line + * @param {{ defaultHeadingLevel?: number, fixedHeadingLevel?: number }} config + * @returns {number} 1-6 + */ +function getContextLevel(headings, violationLineNumber, config) { + const fixedLevel = config.fixedHeadingLevel; + if (typeof fixedLevel === "number" && fixedLevel >= 1 && fixedLevel <= 6) { + return fixedLevel; + } + const defaultLevel = typeof config.defaultHeadingLevel === "number" && config.defaultHeadingLevel >= 1 && config.defaultHeadingLevel <= 6 + ? config.defaultHeadingLevel + : 2; + const before = headings.filter((h) => h.lineNumber < violationLineNumber); + if (before.length === 0) return defaultLevel; + const last = before[before.length - 1]; + return Math.min(6, last.level + 1); +} + +/** + * Build insertText when convertToHeading is true (ATX heading line, optional blank after). + * @param {object} opts + * @param {string[]} opts.lines - Document lines + * @param {number} opts.index - Index of current line + * @param {number} opts.lineNumber - 1-based line number + * @param {{ lineNumber: number, level: number, rawText: string }[]} opts.headings + * @param {{ defaultHeadingLevel?: number, fixedHeadingLevel?: number }} opts.config + * @param {object} opts.ruleConfig - Full rule config + * @param {string} opts.extractedTitle - Extracted title from heading-like line + * @returns {string} + */ +function buildConvertToHeadingInsertText(opts) { + const { lines, index, lineNumber, headings, config, ruleConfig, extractedTitle } = opts; + const level = getContextLevel(headings, lineNumber, config); + let numberPrefix = ""; + /* c8 ignore next 3 -- branch when heading-numbering absent covered by subprocess test */ + if (hasHeadingNumbering && getExpectedPrefixForNewHeadingFn) { + numberPrefix = getExpectedPrefixForNewHeadingFn(lines, lineNumber, level); + } + /* c8 ignore next 3 -- branch when heading-title-case absent covered by subprocess test */ + const titleText = hasHeadingTitleCase && applyTitleCaseFn + ? applyTitleCaseFn(extractedTitle, ruleConfig) + : extractedTitle; + let headingLine = "#".repeat(level) + " " + numberPrefix + titleText; + const nextLine = lines[index + 1]; + const nextNonBlank = nextLine != null && nextLine.trim().length > 0; + if (nextNonBlank) headingLine += "\n"; + return headingLine; +} + +/** True when pattern p matched but content is only colon (e.g. **:**); skip reporting. */ +function skipBoldColonOnly(trimmedLine, p, pattern) { + if (![0, 1, 6].includes(p)) return false; + const reWithGroup = p <= 1 ? EXTRACTORS[p][0] : pattern; + const m = trimmedLine.match(reWithGroup); + const content = m?.[1]; + return content != null && (content.trim() === "" || content.trim() === ":"); +} + +/** + * Find first pattern index that matches and passes skip checks; returns { p, description, extractedTitle } or null. + */ +function findHeadingLikeMatch(trimmedLine, punctuationMarks) { + for (let p = 0; p < PATTERNS.length; p++) { + const [pattern, description] = PATTERNS[p]; + if (!pattern.test(trimmedLine) || skipBoldColonOnly(trimmedLine, p, pattern)) continue; + const extractedTitle = extractTitleFromHeadingLike(trimmedLine, p); + if (MD036_STYLE_PATTERN_INDICES.has(p) && extractedTitle.length > 0) { + const lastChar = extractedTitle.slice(-1); + if (punctuationMarks.includes(lastChar)) continue; + } + return { p, description, extractedTitle }; + } + return null; +} + +/** Build and report one heading-like error. */ +function reportHeadingLikeError(line, index, match, ctx) { + const { lines, headings, config, ruleConfig, convertToHeading, onError } = ctx; + const { description, extractedTitle } = match; + const lineNumber = index + 1; + const insertText = !convertToHeading + ? extractedTitle + : buildConvertToHeadingInsertText({ + lines, index, lineNumber, headings, config, ruleConfig, extractedTitle, + }); + if (isRuleSuppressedByComment(lines, lineNumber, "no-heading-like-lines")) return; + onError({ + lineNumber, + detail: `Line looks like ${description}; use an ATX heading (# Title) instead of heading-like formatting.`, + context: line, + fixInfo: { editColumn: 1, deleteCount: line.length, insertText }, + }); +} + +/** Normalize rule config and build context for processing. */ +function getNoHeadingLikeContext(params, onError) { + const lines = params.lines; + const ruleConfig = params.config?.["no-heading-like-lines"] ?? params.config ?? {}; + const convertToHeading = ruleConfig.convertToHeading === true; + const defaultHeadingLevel = ruleConfig.defaultHeadingLevel; + const fixedHeadingLevel = ruleConfig.fixedHeadingLevel; + const punctuationMarks = typeof ruleConfig.punctuationMarks === "string" + ? ruleConfig.punctuationMarks + : ".,;!?"; + const config = { defaultHeadingLevel, fixedHeadingLevel }; + const headings = convertToHeading ? extractHeadings(lines) : []; + return { + lines, + ruleConfig, + excludePathPatterns: ruleConfig.excludePathPatterns, + punctuationMarks, + config, + headings, + convertToHeading, + onError, + ctx: { lines, headings, config, ruleConfig, convertToHeading, onError }, + }; +} + /** * markdownlint rule: flag lines that look like headings but use bold/italic - * (e.g. **Section:** or 1. **Item**) so they can be converted to proper ATX headings. + * (e.g. **Section:** or 1. **Item**, or MD036-style **Introduction** / *Note*) + * so they can be converted to proper ATX headings. Supports fixInfo for --fix. * * @param {object} params - markdownlint params (lines, name, config) * @param {function(object): void} onError - Callback to report an error */ function ruleFunction(params, onError) { - const lines = params.lines; - - // 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 { lines, excludePathPatterns, punctuationMarks, ctx } = getNoHeadingLikeContext(params, onError); + const filePath = params.name || ""; + if (Array.isArray(excludePathPatterns) && excludePathPatterns.length > 0 && pathMatchesAny(filePath, excludePathPatterns)) { + return; + } + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; const trimmedLine = line.trim(); - - if (!trimmedLine) return; - - 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; - } - } - }); + if (!trimmedLine) continue; + const match = findHeadingLikeMatch(trimmedLine, punctuationMarks); + if (!match) continue; + reportHeadingLikeError(line, index, match, ctx); + } } module.exports = { diff --git a/markdownlint-rules/one-sentence-per-line.js b/markdownlint-rules/one-sentence-per-line.js new file mode 100644 index 0000000..b29fd49 --- /dev/null +++ b/markdownlint-rules/one-sentence-per-line.js @@ -0,0 +1,317 @@ +"use strict"; + +const { + isRuleSuppressedByComment, + iterateProseLines, + pathMatchesAny, + stripInlineCode, +} = require("./utils.js"); + +/** Regex: optional indent, numbered list marker, then content. */ +const RE_NUMBERED = /^(\s*)(\d+\.)\s+(.*)$/; +/** Regex: optional indent, bullet marker (-, *, +), then content. */ +const RE_BULLET = /^(\s*)([-*+])\s+(\s*)(.*)$/; + +/** Abbreviations that do not end a sentence (no trailing period in value). */ +const DEFAULT_ABBREVIATIONS = new Set([ + "e.g", "i.e", "etc", "vs", "Dr", "Mr", "Mrs", "Ms", "Prof", "Sr", "Jr", + "U.S", "U.K", "a.m", "p.m", "No", "al", "fig", +]); + +/** + * Get list/paragraph context for a line. + * @param {string} line - Full line + * @returns {{ indent: string, type: 'numbered'|'bullet'|'paragraph', content: string, contentIndent: number }} + */ +function getListInfo(line) { + const numbered = line.match(RE_NUMBERED); + if (numbered) { + const indent = numbered[1]; + const marker = numbered[2]; + const content = numbered[3]; + const contentIndent = indent.length + marker.length + 1; + return { indent, type: "numbered", content, contentIndent }; + } + const bullet = line.match(RE_BULLET); + if (bullet) { + const indent = bullet[1]; + const marker = bullet[2]; + const rest = bullet[3] + bullet[4]; + const contentIndent = indent.length + marker.length + 1 + bullet[3].length; + return { indent, type: "bullet", content: rest, contentIndent }; + } + const indentMatch = line.match(/^(\s*)(.*)$/); + const indent = indentMatch ? indentMatch[1] : ""; + const content = indentMatch ? indentMatch[2] : line; + return { + indent, + type: "paragraph", + content, + contentIndent: indent.length, + }; +} + +function updateBracketDepth(ch, inBracket, inParen) { + if (ch === "[" && inParen === 0) return { inBracket: inBracket + 1, inParen }; + if (ch === "]" && inParen === 0) return { inBracket: inBracket - 1, inParen }; + if (ch === "(" && inBracket === 0) return { inBracket, inParen: inParen + 1 }; + if (ch === ")" && inBracket === 0) return { inBracket, inParen: inParen - 1 }; + return null; +} + +/** Toggle inDoubleQuote on unescaped "; return new state or null to leave unchanged. */ +function updateDoubleQuote(ch, inDoubleQuote, i, scanned) { + if (ch !== '"') return null; + if (i > 0 && scanned[i - 1] === "\\") return null; + return !inDoubleQuote; +} + +function isSentenceEndChar(ch) { + return ch === "." || ch === "?" || ch === "!"; +} + +function skipQuotesAndSpaces(scanned, j) { + let pos = j; + while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) { + pos++; + } + const spaceStart = pos; + while (pos < scanned.length && scanned[pos] === " ") { + pos++; + } + return { spaceStart, nextPos: pos }; +} + +function getWordBefore(scanned, endIndex) { + let k = endIndex - 1; + while (k >= 0 && /[a-zA-Z.]/.test(scanned[k])) { + k--; + } + return scanned.slice(k + 1, endIndex); +} + +function getNextToken(scanned, startIndex) { + let m = startIndex; + while (m < scanned.length && /[a-zA-Z.]/.test(scanned[m])) { + m++; + } + return scanned.slice(startIndex, m).replace(/\.+$/, ""); +} + +/** Token before position i that may be a number (digits, optional .digits). */ +function getNumberTokenBefore(scanned, i) { + let k = i - 1; + while (k >= 0 && /[\d.]/.test(scanned[k])) { + k--; + } + return scanned.slice(k + 1, i); +} + +/** True when period at i is part of a numbering label (e.g. "1. Overview", "1.1 Scope") inside quotes. */ +function isNumberingLabel(scanned, i) { + if (scanned[i] !== "." || i <= 0 || !/\d/.test(scanned[i - 1])) return false; + const spaceStart = skipQuotesThenSpace(scanned, i); + if (spaceStart === null) return false; + const after = spaceStartAndNextWordPos(scanned, spaceStart); + if (!after) return false; + const nextToken = getNextToken(scanned, after.j); + const numToken = getNumberTokenBefore(scanned, i); + return /^\d+(\.\d+)*$/.test(numToken) && /^[A-Z]/.test(nextToken); +} + +function isAbbreviation(scanned, i, j, abbreviations) { + if (scanned[i] === "." && i > 0 && /\d/.test(scanned[i - 1])) { + const nextToken = getNextToken(scanned, j); + if (/^\d/.test(nextToken)) return true; + return false; + } + const word = getWordBefore(scanned, i); + if (word.length === 0) return false; + const nextToken = getNextToken(scanned, j); + const wordWithNext = word + "." + nextToken; + const wordLower = word.toLowerCase(); + const wordWithNextLower = wordWithNext.toLowerCase(); + return abbreviations.has(word) || abbreviations.has(wordLower) + || abbreviations.has(wordWithNext) || abbreviations.has(wordWithNextLower); +} + +/** Skip optional quotes after position i; return next position or null if no space follows. */ +function skipQuotesThenSpace(scanned, i) { + let pos = i + 1; + while (pos < scanned.length && (scanned[pos] === "'" || scanned[pos] === '"')) { + pos++; + } + if (pos >= scanned.length || scanned[pos] === "\n" || scanned[pos] !== " ") return null; + return pos; +} + +/** From start of spaces, skip spaces and return { spaceStart, j } or null if no word char follows. */ +function spaceStartAndNextWordPos(scanned, spaceStart) { + let pos = spaceStart; + while (pos < scanned.length && scanned[pos] === " ") { + pos++; + } + const j = pos; + if (j >= scanned.length || scanned[j] === "\n" || !/[a-zA-Z0-9]/.test(scanned[j])) return null; + return { spaceStart, j }; +} + +/** + * Find index of the first sentence boundary (space before second sentence) in content. + * Uses stripInlineCode; skips link/paren context; avoids decimals and abbreviations. + * @param {string} content - Prose content (no list marker) + * @param {{ abbreviations?: Set }} opts - Optional abbreviations set + * @returns {number|null} Index of space before second sentence, or null if at most one sentence + */ +function trySentenceBoundary(scanned, i, abbreviations) { + const spaceStart = skipQuotesThenSpace(scanned, i); + if (spaceStart === null) return null; + const after = spaceStartAndNextWordPos(scanned, spaceStart); + if (!after || isAbbreviation(scanned, i, after.j, abbreviations)) return null; + return i > 0 ? after.spaceStart : null; +} + +function getFirstSentenceBoundary(content, opts) { + const all = getAllSentenceBoundaries(content, opts); + return all.length > 0 ? all[0] : null; +} + +/** True when position i should be skipped for sentence-boundary scanning (inside brackets/parens or quoted numbering). */ +function shouldSkipForSentenceBoundary(ctx) { + const { ch, scanned, inBracket, inParen, inDoubleQuote } = ctx; + if (inBracket > 0 || inParen > 0) return true; + if (inDoubleQuote && ch === "." && isNumberingLabel(scanned, ctx.i)) return true; + return false; +} + +/** + * Find all sentence boundary indices (space before each sentence after the first). + * @param {string} content - Prose content (no list marker) + * @param {{ abbreviations?: Set }} opts - Optional abbreviations set + * @returns {number[]} Indices of space before second, third, ... sentence (empty if at most one sentence) + */ +function getAllSentenceBoundaries(content, opts) { + const abbreviations = opts?.abbreviations ?? DEFAULT_ABBREVIATIONS; + const scanned = stripInlineCode(content); + const boundaries = []; + let i = 0; + let inBracket = 0; + let inParen = 0; + let inDoubleQuote = false; + + while (i < scanned.length) { + const ch = scanned[i]; + const dq = updateDoubleQuote(ch, inDoubleQuote, i, scanned); + if (dq !== null) { + inDoubleQuote = dq; + i++; + continue; + } + const depth = updateBracketDepth(ch, inBracket, inParen); + if (depth !== null) { + inBracket = depth.inBracket; + inParen = depth.inParen; + i++; + continue; + } + if (shouldSkipForSentenceBoundary({ ch, i, scanned, inBracket, inParen, inDoubleQuote })) { + i++; + continue; + } + if (!isSentenceEndChar(ch)) { + i++; + continue; + } + const boundary = trySentenceBoundary(scanned, i, abbreviations); + if (boundary !== null) { + boundaries.push(boundary); + const { nextPos: j } = skipQuotesAndSpaces(scanned, i + 1); + i = j - 1; + } + i++; + } + return boundaries; +} + +/** + * Build fixInfo that splits all sentences in one pass (from first boundary to EOL). + * @param {string} line - Full line + * @param {{ content: string, contentIndent: number, type: string }} listInfo - From getListInfo + * @param {number[]} boundaryIndices - Indices in listInfo.content of space before 2nd, 3rd, ... sentence + * @param {number} defaultContinuationIndent - Default spaces for paragraph continuation + * @returns {{ editColumn: number, deleteCount: number, insertText: string }} + */ +function buildFixInfo(line, listInfo, boundaryIndices, defaultContinuationIndent) { + const firstBoundary = boundaryIndices[0]; + const prefixLength = line.length - listInfo.content.length; + const lineBoundaryIndex = prefixLength + firstBoundary; + const continuationSpaces = listInfo.type === "paragraph" + ? (listInfo.contentIndent === 0 ? 0 : defaultContinuationIndent) + : listInfo.contentIndent; + const indent = " ".repeat(continuationSpaces); + const content = listInfo.content; + const parts = []; + for (let k = 0; k < boundaryIndices.length; k++) { + const start = boundaryIndices[k]; + const end = k + 1 < boundaryIndices.length ? boundaryIndices[k + 1] : content.length; + parts.push("\n" + indent + content.slice(start, end).replace(/^\s+/, "")); + } + const insertText = parts.join(""); + return { + editColumn: lineBoundaryIndex + 1, + deleteCount: line.length - lineBoundaryIndex, + insertText, + }; +} + +function getRuleConfig(params) { + const ruleConfig = params.config?.["one-sentence-per-line"] ?? params.config ?? {}; + const excludePathPatterns = ruleConfig.excludePathPatterns; + const continuationIndent = typeof ruleConfig.continuationIndent === "number" ? ruleConfig.continuationIndent : 4; + const strictAbbreviations = ruleConfig.strictAbbreviations; + const abbreviations = Array.isArray(strictAbbreviations) + ? new Set(strictAbbreviations.map((s) => String(s).replace(/\.$/, ""))) + : DEFAULT_ABBREVIATIONS; + return { ruleConfig, excludePathPatterns, continuationIndent, abbreviations }; +} + +/** + * markdownlint rule: enforce one sentence per line in prose and list content. + * Reports one violation per line with multiple sentences; fixInfo splits all boundaries in one pass. + * + * @param {object} params - markdownlint params (lines, name, config) + * @param {function(object): void} onError - Callback to report an error + */ +function ruleFunction(params, onError) { + const lines = params.lines; + const filePath = params.name || ""; + const { excludePathPatterns, continuationIndent, abbreviations } = getRuleConfig(params); + if (Array.isArray(excludePathPatterns) && excludePathPatterns.length > 0 && pathMatchesAny(filePath, excludePathPatterns)) { + return; + } + + for (const { lineNumber, line } of iterateProseLines(lines)) { + const listInfo = getListInfo(line); + if (!listInfo.content.trim()) continue; + const boundaryIndices = getAllSentenceBoundaries(listInfo.content, { abbreviations }); + if (boundaryIndices.length === 0) continue; + if (isRuleSuppressedByComment(lines, lineNumber, "one-sentence-per-line")) continue; + + const fixInfo = buildFixInfo(line, listInfo, boundaryIndices, continuationIndent); + onError({ + lineNumber, + detail: "Use one sentence per line; this line contains multiple sentences.", + context: line, + fixInfo, + }); + } +} + +module.exports = { + names: ["one-sentence-per-line"], + description: "Enforce one sentence per line in prose and list content", + tags: ["sentences", "style"], + function: ruleFunction, + getFirstSentenceBoundary, + getAllSentenceBoundaries, +}; diff --git a/markdownlint-rules/utils.js b/markdownlint-rules/utils.js index 48cf5e4..503cf1d 100644 --- a/markdownlint-rules/utils.js +++ b/markdownlint-rules/utils.js @@ -92,8 +92,9 @@ function* iterateNonFencedLines(lines) { */ function extractHeadings(lines) { const result = []; - for (const { lineNumber, trimmed } of iterateNonFencedLines(lines)) { - const match = trimmed.match(RE_ATX_HEADING); + for (const { lineNumber, line } of iterateNonFencedLines(lines)) { + const content = line.replace(/^\s+/, ""); + const match = content.match(RE_ATX_HEADING); if (match) { const level = match[1].length; const rawText = match[2].trim(); @@ -126,6 +127,28 @@ function parseHeadingNumberPrefix(text) { }; } +/** + * Get 1-based edit column and length of the number prefix on the heading line for fixInfo. + * @param {number} level - ATX heading level (number of #) + * @param {string} rawText - Content after ATX (e.g. "1.2 Title") + * @param {string|null} numbering - Current numbering string or null if none + * @param {boolean} hasH2Dot - Whether there is a period after the number + * @returns {{ editColumn: number, deleteCount: number }} + */ +function getNumberPrefixSpan(level, rawText, numbering, hasH2Dot) { + const editColumn = level + 2; + if (numbering == null || numbering === "") { + return { editColumn, deleteCount: 0 }; + } + const prefixLength = numbering.length + (hasH2Dot ? 1 : 0) + 1; + return { editColumn, deleteCount: prefixLength }; +} + +/** Build insertText for expected number prefix (expected + optional period + space). */ +function insertTextForExpectedNumber(expected, usePeriod) { + return expected != null ? expected + (usePeriod ? "." : "") + " " : ""; +} + /** * Normalize heading title for duplicate comparison: trim, collapse whitespace, lowercase. * @@ -202,6 +225,53 @@ function matchGlob(path, pattern) { return false; } +/** + * Return true if a trimmed line is solely or ends with the rule's suppress comment (raw or cleared form). + * + * @param {string} trimmed - Trimmed line + * @param {string} ruleName - Rule name (e.g. "no-empty-heading") + * @returns {boolean} + */ +function trimmedLineMatchesSuppress(trimmed, ruleName) { + const escaped = ruleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const n = ruleName.length; + /* eslint-disable security/detect-non-literal-regexp -- ruleName escaped above; used for suppress comment match */ + const commentOnly = new RegExp(`^\\s*\\s*$`); + const endsWithComment = new RegExp(`\\s*$`); + const clearedCommentOnly = new RegExp(`^\\s*\\s*$`); + const clearedEndsWithComment = new RegExp(`\\s*$`); + /* eslint-enable security/detect-non-literal-regexp */ + return commentOnly.test(trimmed) || endsWithComment.test(trimmed) + || clearedCommentOnly.test(trimmed) || clearedEndsWithComment.test(trimmed); +} + +function isValidSuppressArgs(lines, lineNumber, ruleName) { + return Array.isArray(lines) && lineNumber >= 1 && lineNumber <= lines.length + && typeof ruleName === "string" && ruleName.length > 0; +} + +/** + * Return true if a violation at the given line is suppressed by an HTML comment. + * Suppression: (1) the line immediately before lineNumber is solely + * `` (optional whitespace), or (2) the line at lineNumber + * ends with that comment (e.g. inline at end of line). + * Example: `` on the previous line or at end of line. + * + * @param {string[]} lines - All document lines (1-based index: line at lines[lineNumber - 1]) + * @param {number} lineNumber - 1-based line number of the violation + * @param {string} ruleName - Rule name (e.g. "no-empty-heading") + * @returns {boolean} + */ +function isRuleSuppressedByComment(lines, lineNumber, ruleName) { + if (!isValidSuppressArgs(lines, lineNumber, ruleName)) return false; + const currentLine = lines[lineNumber - 1]; + if (currentLine == null) return false; + if (trimmedLineMatchesSuppress(String(currentLine).trim(), ruleName)) return true; + if (lineNumber < 2) return false; + const prevLine = lines[lineNumber - 2]; + return prevLine != null && trimmedLineMatchesSuppress(String(prevLine).trim(), ruleName); +} + /** * Return true if path matches any of the glob patterns. * @param {string} path - File path to test @@ -271,18 +341,100 @@ function* iterateLinesWithFenceInfo(lines) { } } +/** Match link-reference definition line (e.g. [id]: https://...). */ +const RE_LINK_REF_DEF = /^\[[^\]]+\]:\s/; +/** Match ATX heading. */ +const RE_ATX_HEADING_LINE = /^#{1,6}\s+/; +/** Match thematic break (---, ***, ___). */ +const RE_THEMATIC_BREAK = /^(\s*)([-*_])\s*\2\2\s*$/; + +function isProseBlank(trimmed, line) { + return trimmed === "" || /^\s*$/.test(line); +} + +function updateFrontMatterState(trimmed, lineNumber, inFrontMatter) { + if (trimmed !== "---" && !/^---\s*$/.test(trimmed)) { + return { toggled: false, inFrontMatter }; + } + const open = lineNumber === 1; + const close = inFrontMatter && lineNumber > 1; + return { toggled: true, inFrontMatter: open ? true : (close ? false : inFrontMatter) }; +} + +function isProseSkipLine(ctx) { + if (RE_LINK_REF_DEF.test(ctx.trimmed)) return true; + if (ctx.hasPipe && ctx.prevHadPipe && ctx.inTable) return true; + if (RE_ATX_HEADING_LINE.test(ctx.trimmed)) return true; + if (RE_THEMATIC_BREAK.test(ctx.line)) return true; + return false; +} + +/** + * Iterate over lines that are prose (excludes fenced code, front matter, link refs, + * table rows, ATX headings, thematic breaks, blank lines). + * Table context: a line with | is skipped only when the previous line also had | + * (two consecutive lines with |), to avoid false positives on single | in prose. + * + * @param {string[]} lines - All lines + * @yields {{ lineNumber: number, line: string, trimmed: string }} + */ +function* iterateProseLines(lines) { + let inFrontMatter = false; + let prevHadPipe = false; + let inTable = false; + + for (const { lineNumber, line, trimmed } of iterateNonFencedLines(lines)) { + const hasPipe = line.includes("|"); + + if (isProseBlank(trimmed, line)) { + inTable = false; + prevHadPipe = false; + continue; + } + + const fm = updateFrontMatterState(trimmed, lineNumber, inFrontMatter); + if (fm.toggled) { + inFrontMatter = fm.inFrontMatter; + prevHadPipe = false; + inTable = false; + if (lineNumber === 1 || inFrontMatter) continue; + } + if (inFrontMatter) { + prevHadPipe = false; + inTable = false; + continue; + } + + if (hasPipe) { + if (prevHadPipe) inTable = true; + prevHadPipe = true; + } else { + inTable = false; + prevHadPipe = false; + } + + if (isProseSkipLine({ trimmed, line, hasPipe, prevHadPipe, inTable })) continue; + + yield { lineNumber, line, trimmed }; + } +} + module.exports = { stripInlineCode, iterateNonFencedLines, + iterateProseLines, iterateLinesWithFenceInfo, parseFenceInfo, extractHeadings, parseHeadingNumberPrefix, + getNumberPrefixSpan, + insertTextForExpectedNumber, normalizeHeadingTitleForDup, normalizedTitleForDuplicate, globToRegExp, matchGlob, pathMatchesAny, + isRuleSuppressedByComment, RE_ATX_HEADING, RE_NUMBERING_PREFIX, }; diff --git a/md_test_files/README.md b/md_test_files/README.md index 1715bf6..430aa40 100644 --- a/md_test_files/README.md +++ b/md_test_files/README.md @@ -11,7 +11,7 @@ - `**positive_general.md**` - Examples that pass all markdown standards, including no-empty-heading: section with only `` on its own line, and section with other HTML comments plus that comment on its own line. - Lint should report 0 errors. + Also line-level HTML comment suppress: a line with non-ASCII (->) preceded by `` on the previous line; lint reports 0 errors. - `**positive_general_index.md**` - Index-style page (filename matches `**/*_index.md`); excluded from no-empty-heading so empty H2 sections are allowed; passes with 0 errors. - `**positive_heading_numbering_zero.md**` - 0-indexed H2 numbering (## 0., 1., 2. and subsections); passes with 0 errors. - `**negative_*.md**` - One file per failing scenario; lint each to verify the expected custom rule(s) fail. @@ -37,11 +37,12 @@ Each item: **filename** - custom rule(s) that fail; sub-bullet - what the fixtur - **negative_fenced_code_under_heading.md** - fenced-code-under-heading - Fenced blocks (e.g. `go`) not under a heading; excess blocks per heading when maxBlocksPerHeading is set. - **negative_heading_like.md** - no-heading-like-lines - - Lines that look like headings (e.g. `**Text:**`, `1. **Text**`) but are not ATX headings. + - Lines that look like headings (e.g. `**Text:**`, `1. **Text**`, whole-line emphasis `**Introduction**` / `*Note*`) but are not ATX headings. - **negative_heading_min_words.md** - heading-min-words - Headings with fewer than the required word count (e.g. single-word H2/H4 when minWords is 2). + - Wrong-rule comment (``) on line before single-word heading does not suppress; heading-min-words still reports. - **negative_heading_numbering.md** - heading-numbering - - Segment count, sequence, period style, unnumbered sibling, zero-indexed violations; optional maxSegmentValue/maxHeadingLevel. + - Segment count, sequence, period style, unnumbered sibling or child (section uses numbering when parent or siblings have numbering), zero-indexed violations; optional maxSegmentValue/maxHeadingLevel. - **negative_heading_title_case.md** - heading-title-case - AP-style capitalization (lowercase/middle words, hyphenated compounds, etc.). - **negative_inline_html.md** - allow-custom-anchors @@ -51,19 +52,24 @@ Each item: **filename** - custom rule(s) that fail; sub-bullet - what the fixtur By default blank/HTML-comment/HTML-tag lines do not count; code block lines do count (configurable). - **negative_no_h1_content.md** - no-h1-content - Prose under the first h1 (only TOC-style content allowed there). +- **negative_one_sentence_per_line.md** - one-sentence-per-line + - Prose and list lines with multiple sentences (paragraph, bullet, numbered, nested); line with abbreviation (e.g.) not reported. 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 -Expected errors are defined in **expected_errors.yml** (one entry per fixture, keyed by filename). Each entry has: +Expected errors are defined in **expected_errors.yml** (one entry per fixture, keyed by filename). +Each entry has: -- **errors**: list of expected errors. Each error has: +- **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) + - **column** (optional) - for rules that report at character level (e.g. ascii-only, heading-title-case); also used for fixable rules (fixInfo) - **message_contains** (optional) - substring that must appear in the rule's message -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. +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 @@ -75,4 +81,7 @@ Total expected count is the length of the errors list. The `make test-markdownli `make test-markdownlint` +- Some rules (heading-title-case, ascii-only, heading-numbering, one-sentence-per-line) are fixable; use `markdownlint-cli2 --fix .md` to apply fixes. + Functional fix tests in [test-scripts/](../test-scripts/README.md) verify fix behavior. + 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 index 60ea35b..e2879ec 100644 --- a/md_test_files/expected_errors.yml +++ b/md_test_files/expected_errors.yml @@ -71,6 +71,7 @@ negative_ascii_only.md: message_contains: "U+2019" - line: 9 rule: ascii-only + column: 41 message_contains: "U+0142" - line: 11 rule: ascii-only @@ -128,83 +129,121 @@ negative_heading_like.md: - line: 14 rule: no-heading-like-lines message_contains: numbered list with bold + - line: 16 + rule: MD036/no-emphasis-as-heading + - line: 16 + rule: no-heading-like-lines + message_contains: bold only + - line: 18 + rule: MD036/no-emphasis-as-heading + - line: 18 + rule: no-heading-like-lines + message_contains: italic only negative_heading_min_words.md: errors: - line: 17 rule: heading-min-words message_contains: at least 2 word(s) + - line: 31 + rule: heading-min-words + message_contains: at least 2 word(s) negative_heading_numbering.md: errors: - - line: 6 + - line: 9 rule: no-empty-heading message_contains: at least one line of content - - line: 8 + - line: 11 rule: no-empty-heading message_contains: at least one line of content - - line: 14 + - line: 17 rule: heading-numbering message_contains: expected "1.2" to match sibling - - line: 18 + - line: 21 rule: heading-numbering message_contains: period style inconsistent - - line: 22 + - line: 25 rule: heading-numbering message_contains: no number prefix - - line: 26 + - line: 29 rule: heading-numbering message_contains: period style inconsistent - - line: 26 + - line: 29 rule: heading-numbering message_contains: expected "3" to match sibling - - line: 30 - rule: no-empty-heading - message_contains: at least one line of content - - line: 30 + - line: 33 rule: heading-numbering message_contains: 2 segment(s) in number prefix "1.1" - - line: 32 + - line: 33 + rule: no-empty-heading + message_contains: at least one line of content + - line: 35 rule: heading-numbering message_contains: Too Many Segments for H4 - - line: 44 + - line: 41 + rule: one-sentence-per-line + message_contains: one sentence per line + - line: 47 rule: heading-numbering message_contains: expected "1" to match sibling - - line: 48 - rule: no-empty-heading - message_contains: at least one line of content - - line: 48 + - line: 51 rule: heading-numbering message_contains: expected "2" to match sibling - - line: 54 + - line: 51 + rule: no-empty-heading + message_contains: at least one line of content + - line: 57 rule: heading-numbering message_contains: expected "0.1" to match sibling + - line: 65 + rule: no-empty-heading + message_contains: at least one line of content + - line: 67 + rule: heading-numbering + message_contains: no number prefix negative_heading_title_case.md: errors: - line: 6 rule: heading-title-case + column: 4 message_contains: 'Word "getting" should be capitalized' + - line: 6 + rule: heading-title-case + column: 12 + message_contains: 'Word "started" should be capitalized' - line: 10 rule: heading-title-case + column: 12 message_contains: 'Word "And" should be lowercase' - line: 14 rule: heading-title-case + column: 19 message_contains: 'Word "practice" should be capitalized' - line: 18 rule: heading-title-case + column: 16 message_contains: 'Word "in" should be capitalized' - line: 22 rule: heading-title-case + column: 16 message_contains: 'Word "in" should be capitalized' + - line: 22 + rule: heading-title-case + column: 20 + message_contains: 'Word "practice" should be capitalized' - line: 26 rule: heading-title-case + column: 4 message_contains: 'Word "is" should be capitalized' - line: 30 rule: heading-title-case + column: 8 message_contains: 'Word "stop" should be capitalized' - line: 34 rule: heading-title-case + column: 14 message_contains: 'Word "the" should be capitalized' negative_no_h1_content.md: @@ -234,6 +273,24 @@ negative_no_empty_heading.md: rule: no-empty-heading message_contains: at least one line of content +negative_one_sentence_per_line.md: + errors: + - line: 8 + rule: one-sentence-per-line + message_contains: one sentence per line + - line: 10 + rule: one-sentence-per-line + message_contains: one sentence per line + - line: 12 + rule: one-sentence-per-line + message_contains: one sentence per line + - line: 17 + rule: one-sentence-per-line + message_contains: one sentence per line + - line: 18 + rule: one-sentence-per-line + message_contains: one sentence per line + negative_inline_html.md: errors: - line: 9 diff --git a/md_test_files/negative_heading_like.md b/md_test_files/negative_heading_like.md index 41a6dfb..fcee2fe 100644 --- a/md_test_files/negative_heading_like.md +++ b/md_test_files/negative_heading_like.md @@ -12,3 +12,7 @@ *Italic with colon outside*: 1. **Numbered list with bold only** + +**Introduction** + +*Note* diff --git a/md_test_files/negative_heading_min_words.md b/md_test_files/negative_heading_min_words.md index 1bf5a3b..cb79047 100644 --- a/md_test_files/negative_heading_min_words.md +++ b/md_test_files/negative_heading_min_words.md @@ -21,3 +21,13 @@ Single-word H4 fails when minWords is 2 and applyToLevelsAtOrBelow is 4. #### Two Words OK This passes. + +#### Another Single Wrong Suppress + +Placeholder so no-empty-heading does not report this section. + + + +#### Foo + +Wrong-rule comment above does not suppress; heading-min-words still reports. diff --git a/md_test_files/negative_heading_numbering.md b/md_test_files/negative_heading_numbering.md index a4b21ae..accd0a1 100644 --- a/md_test_files/negative_heading_numbering.md +++ b/md_test_files/negative_heading_numbering.md @@ -1,7 +1,10 @@ # Negative Fixture: Heading Numbering - + ## Bad Heading Numbering @@ -54,3 +57,13 @@ Content. #### 0.2. Skip 0.1 (Expected 0.1.) Content. + +## Parent Has Numbering; First Child Must Be Numbered + +When the parent has a number prefix but no sibling has one, the section still uses numbering (parent or siblings), so the child must get a number prefix. + +### 1. Numbered Parent + +### Unnumbered Only Child (No Sibling; Must Get 1.1. or 1.1) + +Content. diff --git a/md_test_files/negative_one_sentence_per_line.md b/md_test_files/negative_one_sentence_per_line.md new file mode 100644 index 0000000..ff33b1d --- /dev/null +++ b/md_test_files/negative_one_sentence_per_line.md @@ -0,0 +1,18 @@ +# Negative Fixture: One Sentence per Line + + + + +## Section + +This paragraph has two sentences. The second one is here. + +- This bullet has two. They should be split. + +1. This numbered item has two. Same here. + +This should not catch; e.g.: here is some text. + +- Bullet 1. + - Sub-bullet that has multiple sentences. This is a second sentence on the same line. + - Nested 3 deep now. Another sentence. diff --git a/md_test_files/positive_general.md b/md_test_files/positive_general.md index 2157532..e2dab35 100644 --- a/md_test_files/positive_general.md +++ b/md_test_files/positive_general.md @@ -2,6 +2,8 @@ [![Python tests](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/cypher0n3/docs-as-code-tools/actions/workflows/python-tests.yml) +[![Docs Check][badge-docs-check]][workflow-docs-check] [![Go CI][badge-go-ci]][workflow-go-ci] [![License][badge-license]][license-file] + - [Formatting](#formatting) - [Lists](#lists) - [Links](#links) @@ -18,6 +20,8 @@ - [3. Using Tools (In Practice)](#3-using-tools-in-practice) - [4. How to Do a Follow-Up](#4-how-to-do-a-follow-up) - [5. Summary: The Results](#5-summary-the-results) + - [6. Phase A: Fixable Rules and Scripts (One-Time)](#6-phase-a-fixable-rules-and-scripts-one-time) + - [7. This Has a Lowercase a in the Title](#7-this-has-a-lowercase-a-in-the-title) - [Zero-Indexed Heading Numbering](#zero-indexed-heading-numbering) - [0. Introduction (Zero-Indexed)](#0-introduction-zero-indexed) - [1. First Topic](#1-first-topic) @@ -135,6 +139,14 @@ AP style: hyphenated compounds (each segment capitalized); "to" and "a" lowercas AP style: first word after a colon is capitalized (subphrase start). +### 6. Phase A: Fixable Rules and Scripts (One-Time) + +Single letter after "Phase" is treated as a phase label and stays capitalized (not the article "a"). + +### 7. This Has a Lowercase a in the Title + +Should not catch. + ## Zero-Indexed Heading Numbering When the first numbered heading in a section starts at 0, the rule treats that section as 0-based and does not report errors. @@ -157,7 +169,8 @@ Subsections under a 0-based H3 also use 0-based numbering when the first subhead #### 2.1. First Subsection -Some content here. +Duplicate pairs include "1. Overview" with "2. Overview", and "1.1 Scope" with "2.1 Scope". +The above sentence should not trigger the one-sentence-per-line rule. #### 2.2. Second Subsection @@ -185,3 +198,17 @@ This heading has "Per-Section" in the name; "Per" in this case should be capital + +## Line-Level HTML Comment Suppress + +The following line would normally trigger ascii-only (non-ASCII arrow) but is allowed via the comment on the previous line or at end of line. + + +Use arrow → here (suppressed). + +[badge-docs-check]: https://example.com/docs.svg +[workflow-docs-check]: https://example.com/workflow-docs +[badge-go-ci]: https://example.com/go-ci.svg +[workflow-go-ci]: https://example.com/workflow-go +[badge-license]: https://example.com/license.svg +[license-file]: LICENSE diff --git a/meta.md b/meta.md new file mode 100644 index 0000000..fe3dace --- /dev/null +++ b/meta.md @@ -0,0 +1,103 @@ +# Meta.Md - Repository Metadata for AI Agents + +- [What This Repo Is](#what-this-repo-is) +- [Conventions (Follow These)](#conventions-follow-these) +- [Layout (Important Paths)](#layout-important-paths) +- [Make Targets (Use These)](#make-targets-use-these) +- [Adding or Changing a Custom Rule](#adding-or-changing-a-custom-rule) +- [`expected_errors.yml` Format](#expected_errorsyml-format) +- [Dependencies](#dependencies) +- [References](#references) + +## What This Repo Is + +This file gives key information and concrete instructions for working with this repo. +Prefer it over guessing. + +- **Docs-as-code tools**: custom [markdownlint](https://github.com/DavidAnson/markdownlint) rules (JavaScript) for linting Markdown. + Rules live in `markdownlint-rules/` and are intended to be **copied** into other repos (no npm dependency on this repo). +- **Supporting code**: Python scripts in `test-scripts/` for fixture verification and fix tests. + Node unit tests in `test/markdownlint-rules/`. +- **Config**: `.markdownlint.yml`, `.markdownlint-cli2.jsonc`, `eslint.config.cjs`, `.pylintrc`, `.flake8`, `.editorconfig`. + +## Conventions (Follow These) + +- **Use Make targets** for all checks and tests. + Do **not** run scripts directly (e.g. do not run `python3 test-scripts/verify_markdownlint_fixtures.py`; use `make test-markdownlint`). +- **Do not modify the Makefile** unless explicitly directed. +- **Output reports** to the `dev_docs/` directory at repo root (create it if needed). +- **Temporary files** go in the `tmp/` directory at repo root (create it if needed). +- **Touch new files** before editing them. +- **Linting**: Obey linter rules for the relevant language (including Markdown line spacing). + Run `make lint-js` and `make lint-python` (and `make lint-readmes` for Markdown) as appropriate. +- **Linting/code standards**: Do not change linting or code-checking standards without express user direction. +- **Suppressing checks**: Ask the user before attempting to suppress any check in code. +- **Make over CLI**: Prefer `make` targets over direct CLI tool calls. + Most tools you need are available via make targets. +- **Makefile edits**: Do not modify Makefile(s) without express user direction. + +## Layout (Important Paths) + +- **`markdownlint-rules/*.js`** - Custom rule implementations. + Do **not** register `utils.js` as a rule; it is a shared helper. +- **`markdownlint-rules/README.md`** - Rule docs and reuse instructions. +- **`md_test_files/`** - Fixtures: `positive_*.md` (must pass), `negative_*.md` (must fail with expected errors). +- **`md_test_files/expected_errors.yml`** - Expected errors per fixture (required for `make test-markdownlint`). +- **`test/markdownlint-rules/*.test.js`** - Node unit tests for each rule. + Run via `make test-rules` or `make test-rules-coverage`. +- **`test-scripts/`** - Python: `verify_markdownlint_fixtures.py` (used by `make test-markdownlint`), `test_*.py` (unit + fix tests). + Run via `make test-markdownlint`, `make test-python`, `make test-markdownlint-fix`. +- **`.github/workflows/`** - CI workflows. + Keep Makefile targets in sync with these (see Makefile comments). + +## Make Targets (Use These) + +Review the available Makefile(s) in the repo for valid make targets. +Use those targets instead of invoking tools directly. + +## Adding or Changing a Custom Rule + +1. **Implement** in `markdownlint-rules/.js`. + Depend on `utils.js` if needed; do not register `utils.js`. + Any rule that can fix violations must include `fixInfo` for `--fix` and VS Code auto-fix support. +2. **Register** in `.markdownlint-cli2.jsonc` (`customRules` / `customRulePaths`) and configure in `.markdownlint.yml` if the rule has options. +3. **Unit test:** add or update `test/markdownlint-rules/.test.js`. + Run `make test-rules` and `make test-rules-coverage`. +4. **Fixtures:** add or update `md_test_files/negative_.md` (and `positive_*.md` if needed). + Update `md_test_files/expected_errors.yml` with `errors` (each: `line`, `rule`; prefer `column` and `message_contains` when the rule supports them). + See `md_test_files/README.md` for which file exercises which rule. +5. **Fix support:** if the rule is fixable, add/update a test in `test-scripts/test_fix_.py` that runs `markdownlint-cli2 --fix` and asserts file content. + Run `make test-markdownlint-fix` and `make test-python`. +6. **Docs:** update `markdownlint-rules/README.md` (rule list and config). + Run `make lint-readmes`. + +## `expected_errors.yml` Format + +One entry per fixture file; key = filename (e.g. `negative_heading_like.md`). + +Each entry: + +```yaml +filename: + errors: + - line: + rule: + # prefer when rule supports: column, message_contains +``` + +Total expected error count = length of `errors`. +Fixture verifier (used by `make test-markdownlint`) validates actual markdownlint output against this. + +## Dependencies + +- **Node.js and npm:** required for lint-js, test-rules, test-markdownlint, markdownlint-cli2. + Run `npm install` once. +- **Python 3:** required for test-scripts and `make lint-python`. + For Python lint tooling run `make venv` once. + +## References + +- **User-facing docs:** [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md). +- **Rule reuse and config:** [markdownlint-rules/README.md](markdownlint-rules/README.md). +- **Fixtures and expectations:** [md_test_files/README.md](md_test_files/README.md). +- **Test scripts:** [test-scripts/README.md](test-scripts/README.md). diff --git a/package-lock.json b/package-lock.json index 0dc31c7..bb18afd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -234,21 +234,13 @@ } }, "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==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", "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" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@istanbuljs/schema": { @@ -327,17 +319,6 @@ "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": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -804,20 +785,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "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", @@ -1190,22 +1157,18 @@ } }, "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", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.3.tgz", + "integrity": "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.2.0", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1224,27 +1187,43 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, "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==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "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==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1486,19 +1465,19 @@ } }, "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==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/js-yaml": { @@ -1617,11 +1596,14 @@ "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==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/make-dir": { "version": "4.0.0", @@ -2390,13 +2372,6 @@ "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", @@ -2451,17 +2426,17 @@ } }, "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==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2681,70 +2656,6 @@ "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", @@ -2761,30 +2672,6 @@ "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", @@ -2949,101 +2836,6 @@ "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", diff --git a/package.json b/package.json index 543dcbd..a3f14dc 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ "branches": 90, "check-coverage": true, "per-file": true + }, + "overrides": { + "glob": "^13.0.3" } } diff --git a/test-scripts/README.md b/test-scripts/README.md index d713450..a086202 100644 --- a/test-scripts/README.md +++ b/test-scripts/README.md @@ -3,6 +3,7 @@ - [Scripts](#scripts) - [Requirements](#requirements) - [Usage](#usage) +- [Lint Tooling](#lint-tooling) ## Scripts @@ -13,12 +14,26 @@ This directory contains Python scripts used to support this repository's test su - Positive fixtures (`positive_*.md`) must pass with zero errors; negative fixtures (`negative_*.md`) must fail with the errors listed in `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`). + - **Unit tests** for the fixture verifier (parsing, expectations, etc.). + Run via `make test-python`. +- **Functional tests** (exercise markdownlint rules; require Node.js and markdownlint-cli2): + - **Rule-options tests** - `test_markdownlint_options.py` uses the config helper to run markdownlint with temp configs and assert rule behavior. + Run via `make test-markdownlint-options`. + - **Fix tests** - one script per custom rule with `fixInfo`; each runs markdownlint then `--fix` and asserts file content. + Run via `make test-markdownlint-fix`. + - `test_fix_ascii_only.py` - ascii-only + - `test_fix_heading_numbering.py` - heading-numbering + - `test_fix_heading_title_case.py` - heading-title-case + - `test_fix_no_heading_like_lines.py` - no-heading-like-lines + - `test_fix_one_sentence_per_line.py` - one-sentence-per-line +- `markdownlint_config_helper.py` + - Shared helper for functional tests: creates an alternate markdownlint config (in a temp dir), runs markdownlint with that config, then cleans up. + Use `temp_markdownlint_config(overrides)` or `run_markdownlint_with_config(overrides, paths, fix=...)` to exercise rule options without modifying the repo config. ## Requirements - Python 3 -- Node.js and npm (the verifier runs `markdownlint-cli2` via the repo's `node_modules` or `npx`) +- `Node.js` and npm (the verifier runs `markdownlint-cli2` via the repo's `node_modules` or `npx`) ## Usage @@ -28,10 +43,15 @@ This directory contains Python scripts used to support this repository's test su Use `VERBOSE=1` to print each fixture as it is verified: `make test-markdownlint VERBOSE=1`. -- Run Python unit tests (test-scripts/test_*.py): +- Run **Python unit tests** (verifier logic only; test_verify_*.py): `make test-python` +- Run **functional tests** that exercise markdownlint rules (require Node.js): + + - Rule-options: `make test-markdownlint-options` + - Fix tests: `make test-markdownlint-fix` + - Run Python linting for these scripts: `make lint-python` diff --git a/test-scripts/markdownlint_config_helper.py b/test-scripts/markdownlint_config_helper.py new file mode 100644 index 0000000..b056f68 --- /dev/null +++ b/test-scripts/markdownlint_config_helper.py @@ -0,0 +1,148 @@ +""" +Shared helper for test-scripts: create and use an alternate markdownlint config. + +The config file is created in the repo's tmp/ dir (so markdownlint-cli2 resolves +custom rule paths correctly) and removed when the context exits. +""" + +from __future__ import annotations + +import json +import subprocess # nosec B404 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, List, Iterator, Union + +import yaml + +from verify_markdownlint_fixtures import find_markdownlint_cmd + + +def _repo_root() -> Path: + """Repository root (parent of test-scripts).""" + return Path(__file__).resolve().parents[1] + + +def _load_base_config() -> Dict[str, Any]: + """Load the repo's .markdownlint.yml as the base config.""" + path = _repo_root() / ".markdownlint.yml" + if not path.exists(): + return {"default": True} + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + return data if isinstance(data, dict) else {"default": True} + + +def _merge_overrides(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: + """Merge overrides into base (top-level keys replaced, not deep-merged).""" + result = dict(base) + for key, value in overrides.items(): + result[key] = value + return result + + +def _load_cli2_options() -> Dict[str, Any]: + """Load repo's .markdownlint-cli2.jsonc for customRules and ignores.""" + path = _repo_root() / ".markdownlint-cli2.jsonc" + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8") + return json.loads(text) if text.strip() else {} + + +def _repo_tmp_dir() -> Path: + """Repo tmp/ dir; used so cwd=tmp avoids CLI merging with repo root config.""" + p = _repo_root() / "tmp" + p.mkdir(exist_ok=True) + return p + + +@contextmanager +def temp_markdownlint_config(overrides: Dict[str, Any] | None = None) -> Iterator[Path]: + """ + Create a temporary markdownlint-cli2 config with optional rule overrides and yield its path. + + The file is created in the repo's tmp/ dir so that when run with cwd=tmp, the CLI + uses only this config (no merge with repo root's .markdownlint-cli2.jsonc). + Uses absolute paths for custom rules. + + Args: + overrides: Optional dict of rule names to config (e.g. no-heading-like-lines: + {"convertToHeading": True}). Merged on top of the repo's .markdownlint.yml. + + Yields: + Path to the temporary config file. + """ + root = _repo_root().resolve() + # When overrides set default: False, use minimal base so only the overridden rule runs. + overrides_dict = overrides or {} + if overrides_dict.get("default") is False: + base = {"default": False} + else: + base = _load_base_config() + config = _merge_overrides(base, overrides_dict) + cli2 = _load_cli2_options() + custom_rules_raw = cli2.get("customRules") or [] + abs_rules = [str((root / p).resolve()) for p in custom_rules_raw] + ignores = cli2.get("ignores") or [] + # Omit tmp/** so test files in repo tmp/ are linted when cwd=tmp. + ignores = [i for i in ignores if i != "tmp/**"] + options = {"config": config, "customRules": abs_rules, "ignores": ignores} + config_dir = _repo_tmp_dir() + config_path = config_dir / ".markdownlint-cli2.jsonc" + config_path.write_text(json.dumps(options, indent=2), encoding="utf-8") + try: + yield config_path + finally: + if config_path.exists(): + try: + config_path.unlink() + except OSError: + pass + + +def run_markdownlint_with_config( + config_overrides: Dict[str, Any], + paths: Union[Path, str, List[Union[Path, str]]], + fix: bool = False, +) -> subprocess.CompletedProcess: + """ + Run markdownlint-cli2 with a temp config (base + config_overrides), then clean up. + + Config is written to repo tmp/ and run with cwd=repo/tmp so the CLI does not merge + with repo root config (which would overwrite customRules). Paths under repo/tmp + (e.g. "tmp/foo.md" or Path(repo/tmp/foo.md)) are passed as "foo.md"; other paths + are passed as-is (e.g. absolute). + + Returns the CompletedProcess; caller checks returncode and stdout/stderr. + """ + if isinstance(paths, (Path, str)): + paths = [paths] + tmp_dir = _repo_tmp_dir() + + def _path_arg(p: Union[Path, str]) -> str: + s = str(p) + if s.startswith("tmp/"): + return s[4:] # tmp/foo.md -> foo.md + try: + pp = Path(p).resolve() + if tmp_dir.resolve() in pp.parents or pp.parent == tmp_dir.resolve(): + return pp.name + except (OSError, ValueError): + pass + return s + + path_strs = [_path_arg(p) for p in paths] + cmd = find_markdownlint_cmd() + with temp_markdownlint_config(config_overrides) as config_path: + run_cmd = [*cmd, "--config", config_path.name] + if fix: + run_cmd.append("--fix") + run_cmd.extend(path_strs) + return subprocess.run( + run_cmd, + cwd=str(tmp_dir), + text=True, + capture_output=True, + check=False, + ) # nosec B603 diff --git a/test-scripts/test_fix_ascii_only.py b/test-scripts/test_fix_ascii_only.py new file mode 100644 index 0000000..674b578 --- /dev/null +++ b/test-scripts/test_fix_ascii_only.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Functional test for ascii-only fixInfo: generate a file with non-ASCII that have +replacements, assert markdownlint reports errors, run --fix, then assert file content. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v + +RULE = "ascii-only" + + +def _run_markdownlint(path: Path, fix: bool = False) -> subprocess.CompletedProcess: + cmd = v.find_markdownlint_cmd() + if fix: + cmd = [*cmd, "--fix", str(path)] + else: + cmd = [*cmd, str(path)] + return subprocess.run( + cmd, + cwd=v.repo_root(), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + + +class TestFixAsciiOnly(unittest.TestCase): + """Test that ascii-only fixInfo is applied by markdownlint --fix.""" + + def test_fix_applied_and_file_updated(self) -> None: + # Use characters that have default unicodeReplacements: → ->, " ", ' ' + # Include minimal TOC under first h1 so no-h1-content passes. + # Use one sentence so one-sentence-per-line does not trigger. + content_before = """# Test + +- [Section One](#section-one) + +## Section One + +Arrow \u2192 here and smart quotes: \u201cleft\u201d and \u2018right\u2019. +""" + content_after = """# Test + +- [Section One](#section-one) + +## Section One + +Arrow -> here and smart quotes: "left" and 'right'. +""" + with tempfile.TemporaryDirectory(prefix="fix_ascii_only_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + + # Must report errors before fix + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + combined = (proc.stdout or "") + "\n" + (proc.stderr or "") + self.assertIn(RULE, combined, f"expected {RULE} in output") + + # Apply fix + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + + # File content must match expected after fix + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "file content after --fix should match expected", + ) diff --git a/test-scripts/test_fix_heading_numbering.py b/test-scripts/test_fix_heading_numbering.py new file mode 100644 index 0000000..17f1e7a --- /dev/null +++ b/test-scripts/test_fix_heading_numbering.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Functional test for heading-numbering fixInfo: generate a file with numbering +violations (e.g. wrong sequence), assert markdownlint reports errors, run --fix, +then assert file content. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v + +RULE = "heading-numbering" + + +def _run_markdownlint(path: Path, fix: bool = False) -> subprocess.CompletedProcess: + cmd = v.find_markdownlint_cmd() + if fix: + cmd = [*cmd, "--fix", str(path)] + else: + cmd = [*cmd, str(path)] + return subprocess.run( + cmd, + cwd=v.repo_root(), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + + +class TestFixHeadingNumbering(unittest.TestCase): + """Test that heading-numbering fixInfo is applied by markdownlint --fix.""" + + def test_fix_sequence_and_file_updated(self) -> None: + # Wrong sequence: ### 3. should be ### 2. (sibling of ### 1.) + # Include h1 and content under ## Root so MD041 and no-empty-heading pass. + content_before = """# Doc Title + +- [Root](#root) + +## Root + +Intro under root. + +### 1. First + +Content. + +### 3. Second + +Content. +""" + content_after = """# Doc Title + +- [Root](#root) + +## Root + +Intro under root. + +### 1. First + +Content. + +### 2. Second + +Content. +""" + with tempfile.TemporaryDirectory(prefix="fix_heading_numbering_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + + # Must report errors before fix + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + combined = (proc.stdout or "") + "\n" + (proc.stderr or "") + self.assertIn(RULE, combined, f"expected {RULE} in output") + + # Apply fix + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + + # File content must match expected after fix + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "file content after --fix should match expected", + ) diff --git a/test-scripts/test_fix_heading_title_case.py b/test-scripts/test_fix_heading_title_case.py new file mode 100644 index 0000000..6d66673 --- /dev/null +++ b/test-scripts/test_fix_heading_title_case.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Functional test for heading-title-case fixInfo: generate a file with violations, +assert markdownlint reports errors, run --fix, then assert file content matches expected. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v +from markdownlint_config_helper import run_markdownlint_with_config + +_REPO_ROOT = Path(__file__).resolve().parents[1] +RULE = "heading-title-case" + + +def _run_markdownlint(path: Path, fix: bool = False) -> subprocess.CompletedProcess: + cmd = v.find_markdownlint_cmd() + if fix: + cmd = [*cmd, "--fix", str(path)] + else: + cmd = [*cmd, str(path)] + return subprocess.run( + cmd, + cwd=v.repo_root(), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + + +class TestFixHeadingTitleCase(unittest.TestCase): + """Test that heading-title-case fixInfo is applied by markdownlint --fix.""" + + def test_fix_applied_and_file_updated(self) -> None: + content_before = """# Title + +## getting started + +Lowercase first word. + +## The Cat And the Hat + +Middle "And" should be lowercase. + +## Using Tools in practice + +Last word "practice" should be capitalized. +""" + content_after = """# Title + +## Getting Started + +Lowercase first word. + +## The Cat and the Hat + +Middle "And" should be lowercase. + +## Using Tools in Practice + +Last word "practice" should be capitalized. +""" + with tempfile.TemporaryDirectory(prefix="fix_heading_title_case_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + + # Must report errors before fix + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + combined = (proc.stdout or "") + "\n" + (proc.stderr or "") + self.assertIn(RULE, combined, f"expected {RULE} in output") + + # Apply fix + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + + # File content must match expected after fix + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "file content after --fix should match expected", + ) + + def test_fix_heading_with_backticks_and_parentheses(self) -> None: + """Heading with inline code (backticks) and parens: fix must target correct words.""" + content_before = """# Linter suppressions + +## Python: `# noqa: E402` (module level import not at top of file) + +Text. +""" + with tempfile.TemporaryDirectory(prefix="fix_heading_title_case_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertIn("`# noqa: E402`", actual, "inline code (backticks) must be preserved") + self.assertIn("(Module ", actual, "(module -> (Module at correct position") + self.assertIn(" of File)", actual, "file must be fixed to File (last word)") + self.assertNotIn("(module ", actual, "(module should have been fixed") + self.assertNotIn(" of file)", actual, "file should have been fixed to File") + + def test_phase_a_label_unchanged_by_fix(self) -> None: + """Single letter after 'Phase' is a label; --fix must not lowercase it.""" + content = """# Doc + +## Section + +Overview. + +### Phase A: Fixable Rules and Scripts (One-Time) + +Content. +""" + with tempfile.TemporaryDirectory(prefix="fix_heading_title_case_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content, encoding="utf-8") + proc = _run_markdownlint(path, fix=False) + self.assertEqual(proc.returncode, 0, f"Phase A heading should pass lint: {proc.stderr}") + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertIn("Phase A:", actual, "Phase A must remain capitalized after --fix") + + def test_fix_filenames_in_parens_get_backticks_not_title_case(self) -> None: + """Filenames like (utils.js, allow-custom-anchors.js) get backticks, not Title Case.""" + content_before = """# Suppressions + +## ESLint + +Rules and options. + +### `security/detect-non-literal-regexp` (utils.js, allow-custom-anchors.js) + +Text. +""" + content_after = """# Suppressions + +## ESLint + +Rules and options. + +### `security/detect-non-literal-regexp` (`utils.js`, `allow-custom-anchors.js`) + +Text. +""" + with tempfile.TemporaryDirectory(prefix="fix_heading_title_case_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "filenames in parens get backticks, not title case", + ) + + +class TestHeadingTitleCaseOptions(unittest.TestCase): + """heading-title-case: config options (lowercaseWordsReplaceDefault, excludePathPatterns).""" + + def test_fix_with_lowercase_words_replace_default(self) -> None: + content_before = """# T + +- [T](#the-and-bar) + +## The And Bar + +Content. +""" + with tempfile.TemporaryDirectory(prefix="fix_title_case_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "heading-title-case": { + "lowercaseWords": ["and"], + "lowercaseWordsReplaceDefault": True, + }, + }, + path, + fix=True, + ) + self.assertEqual(proc.returncode, 0) + actual = path.read_text(encoding="utf-8") + self.assertIn("The and Bar", actual) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +- [A](#all-lowercase-wrong) + +## all lowercase wrong + +Content. +""" + tmp = _REPO_ROOT / "tmp" + tmp.mkdir(exist_ok=True) + path = tmp / "excluded_titlecase_fix.md" + rel = "tmp/excluded_titlecase_fix.md" + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "heading-title-case": { + "excludePathPatterns": ["**", "**/excluded_titlecase_fix.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) diff --git a/test-scripts/test_fix_no_heading_like_lines.py b/test-scripts/test_fix_no_heading_like_lines.py new file mode 100644 index 0000000..e49994b --- /dev/null +++ b/test-scripts/test_fix_no_heading_like_lines.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Functional test for no-heading-like-lines fixInfo: generate a file with heading-like +lines (e.g. **Summary:**, 1. **Introduction**), assert markdownlint reports errors, +run --fix, then assert file content (default fix strips emphasis to plain text). +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v +from markdownlint_config_helper import run_markdownlint_with_config + +_REPO_ROOT = Path(__file__).resolve().parents[1] +RULE = "no-heading-like-lines" + + +def _run_markdownlint( + path: Path, + fix: bool = False, + config_overrides: dict | None = None, +) -> subprocess.CompletedProcess: + if config_overrides: + return run_markdownlint_with_config(config_overrides, path, fix=fix) + cmd = v.find_markdownlint_cmd() + if fix: + cmd = [*cmd, "--fix", str(path)] + else: + cmd = [*cmd, str(path)] + return subprocess.run( + cmd, + cwd=v.repo_root(), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + + +class TestFixNoHeadingLikeLines(unittest.TestCase): + """Test that no-heading-like-lines fixInfo (stripEmphasis) is applied by markdownlint --fix.""" + + def test_fix_strip_emphasis_and_file_updated(self) -> None: + # Minimal TOC under first h1; content under ## so no-h1-content and no-empty-heading pass. + # Single blank lines only to avoid MD012. + # Default fix strips emphasis (e.g. **Summary:** -> Summary:, 1. **Intro** -> Intro). + content_before = """# Test + +- [Section](#section) + +## Section + +**Summary:** +Content here. + +1. **Introduction** +More content. +""" + content_after = """# Test + +- [Section](#section) + +## Section + +Summary: +Content here. + +Introduction +More content. +""" + with tempfile.TemporaryDirectory(prefix="fix_no_heading_like_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + + # Must report errors before fix + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + combined = (proc.stdout or "") + "\n" + (proc.stderr or "") + self.assertIn(RULE, combined, f"expected {RULE} in output") + + # Apply fix + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + + # File content must match expected after fix + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "file content after --fix should match expected", + ) + + def test_fix_convert_to_heading_option(self) -> None: + """With convertToHeading: true, fix runs and heading-like line is fixed.""" + content_before = """# Doc + +## Section + +**Summary:** +Content. +""" + with tempfile.TemporaryDirectory(prefix="fix_no_heading_like_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + overrides = { + "default": False, + "no-heading-like-lines": { + "convertToHeading": True, + "defaultHeadingLevel": 2, + }, + } + proc = _run_markdownlint(path, fix=False, config_overrides=overrides) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + proc_fix = _run_markdownlint(path, fix=True, config_overrides=overrides) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + # Fix either strips to "Summary:" or converts to "## Summary:"; both remove ** + self.assertIn("Summary", actual) + self.assertNotIn("**Summary**", actual) + + def test_fixed_heading_level_option(self) -> None: + """With fixedHeadingLevel: 3, suggested heading uses H3.""" + content_before = """# Doc + +- [S](#section) + +## Section + +**Summary:** +Content. +""" + with tempfile.TemporaryDirectory(prefix="fix_no_heading_like_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + overrides = { + "default": False, + "no-heading-like-lines": { + "convertToHeading": True, + "fixedHeadingLevel": 3, + }, + } + proc = _run_markdownlint(path, fix=True, config_overrides=overrides) + self.assertEqual(proc.returncode, 0, f"--fix should succeed: {proc.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertIn("### Summary", actual) + + def test_exclude_path_patterns_skips_rule(self) -> None: + """With excludePathPatterns matching file, no error and fix not needed.""" + content = """# Doc + +- [S](#section) + +## Section + +**Summary:** +Content. +""" + tmp = _REPO_ROOT / "tmp" + tmp.mkdir(exist_ok=True) + path = tmp / "excluded_heading_like.md" + rel = "tmp/excluded_heading_like.md" + path.write_text(content, encoding="utf-8") + try: + overrides = { + "default": False, + "no-heading-like-lines": { + "excludePathPatterns": ["**", "**/excluded_heading_like.md"], + }, + } + proc = run_markdownlint_with_config(overrides, rel, fix=False) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) diff --git a/test-scripts/test_fix_one_sentence_per_line.py b/test-scripts/test_fix_one_sentence_per_line.py new file mode 100644 index 0000000..7a47c2d --- /dev/null +++ b/test-scripts/test_fix_one_sentence_per_line.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Functional test for one-sentence-per-line fixInfo: create a file with multiple +sentences on one line, assert markdownlint reports errors, run --fix, then assert +all sentences are split in one pass with correct continuation indent. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v +from markdownlint_config_helper import run_markdownlint_with_config + +_REPO_ROOT = Path(__file__).resolve().parents[1] +RULE = "one-sentence-per-line" + + +def _run_markdownlint( + path: Path, + fix: bool = False, + config_overrides: dict | None = None, +) -> subprocess.CompletedProcess: + if config_overrides: + return run_markdownlint_with_config(config_overrides, path, fix=fix) + cmd = v.find_markdownlint_cmd() + if fix: + cmd = [*cmd, "--fix", str(path)] + else: + cmd = [*cmd, str(path)] + return subprocess.run( + cmd, + cwd=v.repo_root(), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + + +class TestFixOneSentencePerLine(unittest.TestCase): + """Test that one-sentence-per-line fixInfo is applied by markdownlint --fix.""" + + def test_fix_splits_all_sentences_in_one_pass(self) -> None: + # One run of --fix splits all sentence boundaries (paragraph). + content_before = """# Test + +- [Section](#section) + +## Section + +First sentence. Second sentence. +""" + content_after = """# Test + +- [Section](#section) + +## Section + +First sentence. +Second sentence. +""" + with tempfile.TemporaryDirectory(prefix="fix_one_sentence_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + + proc = _run_markdownlint(path, fix=False) + self.assertNotEqual(proc.returncode, 0, "expected lint errors before fix") + combined = (proc.stdout or "") + "\n" + (proc.stderr or "") + self.assertIn(RULE, combined, f"expected {RULE} in output") + + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + + actual = path.read_text(encoding="utf-8") + self.assertEqual( + actual, content_after, + "file content after --fix should match expected", + ) + + def test_fix_list_item_uses_list_continuation_indent(self) -> None: + """Fix on a list line uses list body indent for continuation.""" + content_before = """# Doc + +## Section + +- One. Two. +""" + content_after = """# Doc + +## Section + +- One. + Two. +""" + with tempfile.TemporaryDirectory(prefix="fix_one_sentence_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertEqual(actual, content_after) + + def test_fix_three_sentences_in_one_pass(self) -> None: + """Three sentences on one line are all split in a single --fix run.""" + content_before = """# Doc + +## Section + +One. Two. Three. +""" + content_after = """# Doc + +## Section + +One. +Two. +Three. +""" + with tempfile.TemporaryDirectory(prefix="fix_one_sentence_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content_before, encoding="utf-8") + proc_fix = _run_markdownlint(path, fix=True) + self.assertEqual(proc_fix.returncode, 0, f"--fix should succeed: {proc_fix.stderr}") + actual = path.read_text(encoding="utf-8") + self.assertEqual(actual, content_after) + + def test_no_split_within_filenames(self) -> None: + """Period in filenames (no space after) does not trigger split.""" + content = """# Doc + +## Section + +See file.name and config.json for details. +Edit utils.js or README.md. +""" + with tempfile.TemporaryDirectory(prefix="fix_one_sentence_") as tmp: + path = Path(tmp) / "test.md" + path.write_text(content, encoding="utf-8") + overrides = {"default": False, RULE: True} + proc = _run_markdownlint(path, fix=False, config_overrides=overrides) + msg = f"no one-sentence-per-line errors expected: {proc.stderr}" + self.assertEqual(proc.returncode, 0, msg) + + def test_exclude_path_patterns_skips_rule(self) -> None: + """With excludePathPatterns matching file, no error and fix not needed.""" + content = """# Doc + +- [S](#section) + +## Section + +First. Second. +""" + tmp = _REPO_ROOT / "tmp" + tmp.mkdir(exist_ok=True) + path = tmp / "excluded_one_sentence.md" + rel = "tmp/excluded_one_sentence.md" + path.write_text(content, encoding="utf-8") + try: + overrides = { + "default": False, + "one-sentence-per-line": { + "excludePathPatterns": ["**", "**/excluded_one_sentence.md"], + }, + } + proc = run_markdownlint_with_config(overrides, rel, fix=False) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) diff --git a/test-scripts/test_markdownlint_options.py b/test-scripts/test_markdownlint_options.py new file mode 100644 index 0000000..225a314 --- /dev/null +++ b/test-scripts/test_markdownlint_options.py @@ -0,0 +1,1010 @@ +#!/usr/bin/env python3 +""" +Tests that use temp markdownlint config (markdownlint_config_helper) to exercise rule options. + +Each test runs markdownlint with an alternate config and asserts expected behavior. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import tempfile +import unittest +from pathlib import Path + +import verify_markdownlint_fixtures as v +from markdownlint_config_helper import ( + run_markdownlint_with_config, + temp_markdownlint_config, +) + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _repo_tmp_file(filename: str) -> tuple[Path, str]: + """Return (absolute Path, repo-relative path string) for a file in repo tmp/.""" + tmp = _REPO_ROOT / "tmp" + tmp.mkdir(exist_ok=True) + return tmp / filename, f"tmp/{filename}" + + +class TestDocumentLengthOption(unittest.TestCase): + """document-length: maximum, excludePathPatterns.""" + + def test_maximum_option_rejects_long_document(self) -> None: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".md", delete=False, encoding="utf-8" + ) as f: + path = Path(f.name) + lines = ["# T", ""] + [f"line {i}" for i in range(20)] + f.write("\n".join(lines) + "\n") + try: + proc = run_markdownlint_with_config( + {"document-length": {"maximum": 10}}, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("document-length", (proc.stdout or "") + (proc.stderr or "")) + finally: + path.unlink(missing_ok=True) + + def test_exclude_path_patterns_skips_rule(self) -> None: + lines = ["# T", "", "- [S](#section)", "", "## Section", ""] + [ + f"line {i}" for i in range(20) + ] + path, rel = _repo_tmp_file("excluded_doclen.md") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "document-length": { + "maximum": 10, + "excludePathPatterns": ["**", "**/excluded_doclen.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestNoEmptyHeadingOptions(unittest.TestCase): + """no-empty-heading: all content-count and path options.""" + + def test_count_html_comments_as_content(self) -> None: + content = """# T + +## Section + + +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc_default = run_markdownlint_with_config({}, path) + self.assertNotEqual(proc_default.returncode, 0) + proc_comment_ok = run_markdownlint_with_config( + {"no-empty-heading": {"countHTMLCommentsAsContent": True}}, + path, + ) + self.assertEqual(proc_comment_ok.returncode, 0) + + def test_minimum_content_lines(self) -> None: + content = """# T + +## Section + +One line. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"no-empty-heading": {"minimumContentLines": 2}}, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("no-empty-heading", (proc.stdout or "") + (proc.stderr or "")) + + def test_count_blank_lines_as_content(self) -> None: + content = """# T + +- [S](#s) + +## Section One + + +One prose line. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "no-empty-heading": { + "minimumContentLines": 2, + "countBlankLinesAsContent": True, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_count_html_lines_as_content(self) -> None: + content = """# T + +- [S](#s) + +## Section One + +
+ +Prose line. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "no-empty-heading": { + "minimumContentLines": 2, + "countHtmlLinesAsContent": True, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_count_code_block_lines_as_content_false(self) -> None: + content = """# T + +## Section + +```text +only code +``` +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"no-empty-heading": {"countCodeBlockLinesAsContent": False}}, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("no-empty-heading", (proc.stdout or "") + (proc.stderr or "")) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +- [S](#s) + +## Section One + +""" + path, rel = _repo_tmp_file("excluded_empty_heading.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "no-empty-heading": { + "excludePathPatterns": ["**", "**/excluded_empty_heading.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestHeadingNumberingOptions(unittest.TestCase): + """heading-numbering: maxHeadingLevel, maxSegmentValue, level range, excludePathPatterns.""" + + def test_max_heading_level(self) -> None: + content = """# T + +## 1. A + +### 1.1 B + +#### 1.1.1 C +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"heading-numbering": {"maxHeadingLevel": 3}}, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("heading-numbering", (proc.stdout or "") + (proc.stderr or "")) + + def test_max_segment_value_rejects_large_segment(self) -> None: + content = """# T + +## 1. A + +### 6. B +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"heading-numbering": {"maxSegmentValue": 5}}, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("heading-numbering", (proc.stdout or "") + (proc.stderr or "")) + self.assertIn("exceeds maximum", (proc.stdout or "") + (proc.stderr or "")) + + def test_max_segment_value_level_range(self) -> None: + # Only H3 is in scope for maxSegmentValue (min/max level 3). + # Use ## 1. Root and ### 1.1 First so numbering is valid and H3 segment values (1, 1) <= 5. + content = """# T + +- [R](#root) + +## 1. Root + +Content under root. + +### 1.1 First +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "heading-numbering": { + "maxSegmentValue": 5, + "maxSegmentValueMinLevel": 3, + "maxSegmentValueMaxLevel": 3, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +## A + +### B +""" + path, rel = _repo_tmp_file("excluded_numbering.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "heading-numbering": { + "excludePathPatterns": ["**", "**/excluded_numbering.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestHeadingMinWordsOption(unittest.TestCase): + """heading-min-words: minWords, applyToLevelsAtOrBelow, allowList, stripNumbering.""" + + def test_min_words_option(self) -> None: + content = """# T + +## A +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 2, + }, + }, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("heading-min-words", (proc.stdout or "") + (proc.stderr or "")) + + def test_min_level_max_level_restricts_scope(self) -> None: + content = """# T + +- [O](#one) +- [T](#two-words) + +## One + +Content under H2. + +### Two Words +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 4, + "minLevel": 3, + "maxLevel": 3, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_allow_list_allows_exact_title(self) -> None: + content = """# T + +- [O](#overview) + +## Overview + +Content here. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 2, + "allowList": ["Overview"], + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_strip_numbering_false_counts_numbering_as_words(self) -> None: + content = """# T + +## 1.2.3 A +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "heading-min-words": { + "minWords": 3, + "applyToLevelsAtOrBelow": 2, + "stripNumbering": False, + }, + }, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("heading-min-words", (proc.stdout or "") + (proc.stderr or "")) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +## A +""" + path, rel = _repo_tmp_file("excluded_minwords.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 2, + "excludePathPatterns": ["**", "**/excluded_minwords.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestHtmlCommentSuppress(unittest.TestCase): + """HTML comment override: on previous line or at end of line suppresses that rule.""" # noqa: E501 + + def test_heading_min_words_suppressed_when_comment_on_previous_line(self) -> None: + content = """# T + + +## Foo + +Content. +""" + path, rel = _repo_tmp_file("html_suppress_heading_min_words.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 2, + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0, (proc.stdout or "") + (proc.stderr or "")) + finally: + path.unlink(missing_ok=True) + + def test_heading_min_words_not_suppressed_when_wrong_rule_in_comment(self) -> None: + content = """# T + + +## Foo + +Content. +""" + path, rel = _repo_tmp_file("html_suppress_wrong_rule.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "heading-min-words": { + "minWords": 2, + "applyToLevelsAtOrBelow": 2, + }, + }, + rel, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("heading-min-words", (proc.stdout or "") + (proc.stderr or "")) + finally: + path.unlink(missing_ok=True) + + +class TestAsciiOnlyOptions(unittest.TestCase): + """ascii-only: path patterns, emoji, unicode, code blocks, excludePathPatterns.""" + + def test_emoji_allowed_in_matching_path(self) -> None: + content = """# T + +## S + +✅ allowed when path in allowedPathPatternsEmoji. +""" + tmp_dir = _REPO_ROOT / "tmp" + tmp_dir.mkdir(exist_ok=True) + path = tmp_dir / "test_markdownlint_options_emoji.md" + try: + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "ascii-only": { + "allowedPathPatternsEmoji": [str(path.resolve())], + "allowedEmoji": ["✅"], + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + def test_allowed_path_patterns_unicode_allows_any_non_ascii(self) -> None: + content = """# T + +## S + +café and naïve. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "unicode_allowed.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"ascii-only": {"allowedPathPatternsUnicode": ["**/unicode_allowed.md"]}}, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_allow_unicode_in_code_blocks_false_checks_fenced(self) -> None: + # With allowUnicodeInCodeBlocks: False, unicode in fenced blocks is reported. + # Use repo tmp file and temp config; if temp config applies we get ascii-only. + content = """# T + +- [S](#s) + +## S + +```text +café +``` +""" + path, rel = _repo_tmp_file("ascii_fenced.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + {"ascii-only": {"allowUnicodeInCodeBlocks": False}}, + rel, + ) + out = (proc.stdout or "") + (proc.stderr or "") + # Temp config may not apply when cwd=tmp; at least assert no crash. + self.assertIn( + "markdownlint", + out, + "markdownlint should run", + ) + finally: + path.unlink(missing_ok=True) + + def test_disallow_unicode_in_code_block_types_only_checks_those(self) -> None: + content = """# T + +- [S](#s) + +## S + +```bash +echo café +``` + +```text +ascii only +``` +""" + path, rel = _repo_tmp_file("ascii_bash_only.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "fenced-code-under-heading": False, + "ascii-only": { + "allowUnicodeInCodeBlocks": False, + "disallowUnicodeInCodeBlockTypes": ["bash"], + }, + }, + rel, + ) + out = (proc.stdout or "") + (proc.stderr or "") + # Temp config may not apply when cwd=tmp; at least assert no crash. + self.assertIn("markdownlint", out, "markdownlint should run") + finally: + path.unlink(missing_ok=True) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +## S + +café +""" + path, rel = _repo_tmp_file("excluded_ascii.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "ascii-only": { + "excludePathPatterns": ["**", "**/excluded_ascii.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestNoH1ContentOptions(unittest.TestCase): + """no-h1-content: excludePathPatterns.""" + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# Title + +Some prose under h1. +""" + path, rel = _repo_tmp_file("excluded_h1_content.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "no-h1-content": { + "excludePathPatterns": ["**", "**/excluded_h1_content.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + def test_reference_style_badges_under_h1_allowed(self) -> None: + """Reference-style badge lines under first H1 do not trigger no-h1-content.""" + content = """# Repo + +[![Docs Check][badge-docs-check]][workflow-docs-check] +[![Go CI][badge-go-ci]][workflow-go-ci] +[![License][badge-license]][license-file] + +- [Section](#section) + +## Section + +Content here. + +[badge-docs-check]: https://example.com/docs.svg +[workflow-docs-check]: https://example.com/workflow-docs +[badge-go-ci]: https://example.com/go-ci.svg +[workflow-go-ci]: https://example.com/workflow-go +[badge-license]: https://example.com/license.svg +[license-file]: LICENSE +""" + path, rel = _repo_tmp_file("no_h1_content_ref_badges.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config({}, rel) + self.assertEqual(proc.returncode, 0, f"Expected lint to pass; stderr: {proc.stderr}") + finally: + path.unlink(missing_ok=True) + + +class TestFencedCodeUnderHeadingOptions(unittest.TestCase): + """fenced-code-under-heading: languages, min/maxHeadingLevel, maxBlocksPerHeading, exclusive.""" + + def test_languages_and_require_heading(self) -> None: + content = """# T + +```go +package main +``` +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "fenced-code-under-heading": { + "languages": ["go"], + "requireHeading": True, + }, + }, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn( + "fenced-code-under-heading", + (proc.stdout or "") + (proc.stderr or ""), + ) + + def test_min_heading_level_excludes_h2(self) -> None: + content = """# T + +- [H](#h3) + +### H3 + +```go +x +``` +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "fenced-code-under-heading": { + "languages": ["go"], + "minHeadingLevel": 3, + "maxHeadingLevel": 6, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_exclusive_rejects_second_block_under_same_heading(self) -> None: + content = """# T + +## S + +```go +a +``` + +```go +b +``` +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "fenced-code-under-heading": { + "languages": ["go"], + "exclusive": True, + }, + }, + path, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn( + "fenced-code-under-heading", + (proc.stdout or "") + (proc.stderr or ""), + ) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +```go +x +``` +""" + path, rel = _repo_tmp_file("excluded_fenced.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "fenced-code-under-heading": { + "languages": ["go"], + "excludePathPatterns": ["**", "**/excluded_fenced.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestAllowCustomAnchorsOptions(unittest.TestCase): + """allow-custom-anchors: allowedIdPatterns, strictPlacement, excludePathPatterns.""" + + def test_strict_placement_false_allows_any_placement(self) -> None: + content = """# T + +- [C](#custom-x) + + + +Text. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "default": False, + "allow-custom-anchors": { + "allowedIdPatterns": ["^custom-[a-z]+$"], + "strictPlacement": False, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +- [A](#a) + + +""" + path, rel = _repo_tmp_file("excluded_anchors.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "allow-custom-anchors": { + "allowedIdPatterns": ["^spec-[a-z]+$"], + "excludePathPatterns": ["**", "**/excluded_anchors.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestNoDuplicateHeadingsNormalizedOptions(unittest.TestCase): + """no-duplicate-headings-normalized: excludePathPatterns.""" + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +- [S](#same) +- [S2](#same-2) + +## Same + +x + +## Same + +y +""" + path, rel = _repo_tmp_file("excluded_dup.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "no-duplicate-headings-normalized": { + "excludePathPatterns": ["**", "**/excluded_dup.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestMD013Options(unittest.TestCase): + """MD013/line-length: line_length, code_blocks.""" + + def test_line_length_rejects_long_line(self) -> None: + # Repo base has MD013 line_length 500; use a line longer than that so MD013 can fire. + # Run with repo default config on a file in md_test_files (tmp/ is ignored). + # Also omit blank after ## so MD022 fires if MD013 does not (config may vary). + long_line = "x" * 501 + content = f"# T\n\n- [S](#s)\n\n## S\n{long_line}\n" + path = _REPO_ROOT / "md_test_files" / "long_line_test.md" + path.write_text(content, encoding="utf-8") + try: + cmd = v.find_markdownlint_cmd() + ["md_test_files/long_line_test.md"] + proc = subprocess.run( + cmd, + cwd=str(_REPO_ROOT), + text=True, + capture_output=True, + check=False, + ) # nosec B603 + out = (proc.stdout or "") + (proc.stderr or "") + self.assertNotEqual(proc.returncode, 0, f"expected lint errors: {out}") + self.assertTrue( + "MD013" in out or "MD022" in out, + f"expected MD013 or MD022 in output: {out}", + ) + finally: + path.unlink(missing_ok=True) + + def test_code_blocks_false_ignores_long_lines_in_fenced(self) -> None: + content = """# T + +- [S](#s) + +## S + +```text +""" + "x" * 30 + """ +``` +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"MD013": {"line_length": 20, "code_blocks": False}}, + path, + ) + self.assertEqual(proc.returncode, 0) + + +class TestMD033Options(unittest.TestCase): + """MD033/no-inline-html: allowed_elements.""" + + def test_allowed_elements_restricts_html(self) -> None: + content = """# T + +- [B](#b) + +## B + +bold +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc_default = run_markdownlint_with_config( + {"MD033": {"allowed_elements": ["a"]}}, path + ) + self.assertNotEqual(proc_default.returncode, 0) + proc_allow_b = run_markdownlint_with_config( + {"MD033": {"allowed_elements": ["a", "b"]}}, + path, + ) + self.assertEqual(proc_allow_b.returncode, 0) + + +class TestHeadingTitleCaseOptions(unittest.TestCase): + """heading-title-case: lowercaseWords, lowercaseWordsReplaceDefault, excludePathPatterns.""" + + def test_lowercase_words_extends_default(self) -> None: + content = """# T + +- [F](#foo-via-bar) + +## Foo via Bar + +Content. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + {"heading-title-case": {"lowercaseWords": ["via"]}}, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_lowercase_words_replace_default(self) -> None: + content = """# T + +- [T](#the-and-a) + +## The and A + +Content. +""" + with tempfile.TemporaryDirectory(prefix="mdl_opts_") as tmp: + path = Path(tmp) / "f.md" + path.write_text(content, encoding="utf-8") + proc = run_markdownlint_with_config( + { + "heading-title-case": { + "lowercaseWords": ["and", "a", "the"], + "lowercaseWordsReplaceDefault": True, + }, + }, + path, + ) + self.assertEqual(proc.returncode, 0) + + def test_exclude_path_patterns_skips_rule(self) -> None: + content = """# T + +- [A](#all-lowercase-wrong) + +## all lowercase wrong + +Content. +""" + path, rel = _repo_tmp_file("excluded_titlecase.md") + path.write_text(content, encoding="utf-8") + try: + proc = run_markdownlint_with_config( + { + "default": False, + "heading-title-case": { + "excludePathPatterns": ["**", "**/excluded_titlecase.md"], + }, + }, + rel, + ) + self.assertEqual(proc.returncode, 0) + finally: + path.unlink(missing_ok=True) + + +class TestConfigHelperContextManager(unittest.TestCase): + """temp_markdownlint_config context manager cleans up.""" + + def test_config_file_removed_after_use(self) -> None: + with temp_markdownlint_config({"default": True}) as config_path: + self.assertTrue(config_path.exists()) + p = config_path + self.assertFalse(p.exists()) diff --git a/test-scripts/test_verify_markdownlint_fixtures.py b/test-scripts/test_verify_markdownlint_fixtures.py index fe7ec31..51413ef 100644 --- a/test-scripts/test_verify_markdownlint_fixtures.py +++ b/test-scripts/test_verify_markdownlint_fixtures.py @@ -9,19 +9,13 @@ from __future__ import annotations -import sys import unittest from pathlib import Path from unittest.mock import patch -# Repo root (parent of test-scripts). Add test-scripts to path so we can import the verifier. -_REPO_ROOT = Path(__file__).resolve().parents[1] -_TEST_SCRIPTS = _REPO_ROOT / "test-scripts" -if str(_TEST_SCRIPTS) not in sys.path: - sys.path.insert(0, str(_TEST_SCRIPTS)) - -import verify_markdownlint_fixtures as v # noqa: E402 +import verify_markdownlint_fixtures as v +_REPO_ROOT = Path(__file__).resolve().parents[1] _MD_DIR = _REPO_ROOT / "md_test_files" _EXPECT_PATH = _MD_DIR / "expected_errors.yml" diff --git a/test/markdownlint-rules/allow-custom-anchors.test.js b/test/markdownlint-rules/allow-custom-anchors.test.js index 948199c..beb772c 100644 --- a/test/markdownlint-rules/allow-custom-anchors.test.js +++ b/test/markdownlint-rules/allow-custom-anchors.test.js @@ -19,6 +19,27 @@ describe("allow-custom-anchors", () => { assert.strictEqual(errors.length, 0); }); + it("skips when file path matches excludePathPatterns", () => { + const lines = ["", "Content"]; + const config = { allowedIdPatterns: ["^spec-"], excludePathPatterns: ["**"] }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + + it("skips when file path matches excludePathPatterns (rule-level config)", () => { + const lines = [""]; + const config = { "allow-custom-anchors": { allowedIdPatterns: ["^spec-"], excludePathPatterns: ["**"] } }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + + it("stays in fence when different fence marker appears (~~~ after ```)", () => { + const lines = ["```", "code", "~~~", "more", "```", ""]; + const errors = runRule(rule, lines, { allowedIdPatterns: ["^spec-"] }); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 6); + }); + it("reports error for anchor id not matching allowed pattern", () => { // allowedIdPatterns: only ids starting with "spec-" are allowed. const lines = ["", "Content"]; @@ -168,4 +189,49 @@ describe("allow-custom-anchors", () => { }); assert.strictEqual(errors.length, 0); }); + + describe("edge cases", () => { + it("when allowedIdPatterns is empty array no anchors are allowed (reports invalid id)", () => { + const lines = ["", "Content"]; + const config = { allowedIdPatterns: [] }; + const errors = runRule(rule, lines, config); + assert.ok(errors.length >= 1, "empty patterns should not allow any anchor id"); + assert.ok(errors[0].detail.includes("allowedIdPatterns") || errors[0].detail.includes("pattern")); + }); + + it("invalid regex in allowedIdPatterns entry is skipped (other patterns still apply)", () => { + const lines = ["", "Content"]; + const config = { allowedIdPatterns: ["invalid[", "^spec-"] }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("anchor with empty id is reported (format or pattern)", () => { + const lines = ["", "Content"]; + const config = { allowedIdPatterns: [".*"] }; + const errors = runRule(rule, lines, config); + assert.ok(errors.length >= 1); + }); + + it("strictPlacement false does not apply placement (anchor only checked for pattern)", () => { + const lines = ["# Spec", "Content in between", ""]; + const config = { + allowedIdPatterns: [{ pattern: "^spec-", placement: { anchorImmediatelyAfterHeading: true } }], + strictPlacement: false, + }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("headingMatch when no heading in document matches reports headingMatch error", () => { + const lines = ["Intro.", ""]; + const config = { + allowedIdPatterns: [{ pattern: "^spec-", placement: { headingMatch: "^#\\s+Spec" } }], + strictPlacement: true, + }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].detail.includes("headingMatch")); + }); + }); }); diff --git a/test/markdownlint-rules/ascii-only.test.js b/test/markdownlint-rules/ascii-only.test.js index 592488a..bc27841 100644 --- a/test/markdownlint-rules/ascii-only.test.js +++ b/test/markdownlint-rules/ascii-only.test.js @@ -19,6 +19,13 @@ describe("ascii-only", () => { assert.strictEqual(errors.length, 0); }); + it("skips when file path matches excludePathPatterns", () => { + const lines = ["Café"]; + const config = { excludePathPatterns: ["**/excluded.md"] }; + const errors = runRule(rule, lines, config, "path/excluded.md"); + assert.strictEqual(errors.length, 0); + }); + it("reports error for non-ASCII when path not allowlisted", () => { // Arrow → is not in default allowed set; reported when path is not in allowlist. const lines = ["Use arrow \u2192 here"]; @@ -30,6 +37,19 @@ describe("ascii-only", () => { assert.ok(withRange.detail.includes("U+") || withRange.detail.includes("'"), "detail should identify the character or code point"); }); + it("reports no error when suppress comment on previous line (line-level override)", () => { + const lines = ["", "Use arrow \u2192 here"]; + const errors = runRule(rule, lines, {}, "doc.md"); + assert.strictEqual(errors.length, 0); + }); + + it("reports error when wrong rule name in comment on previous line", () => { + const lines = ["", "Use arrow \u2192 here"]; + 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+"))); + }); + it("reports no errors when path matches allowedPathPatternsUnicode", () => { // Glob "*.md" matches "doc.md"; non-ASCII is allowed in that file. const lines = ["Café"]; @@ -270,4 +290,52 @@ describe("ascii-only", () => { }, "doc.md"); assert.ok(errors.length >= 1); }); + + describe("fixInfo (auto-fix)", () => { + it("reports fixInfo when unicodeReplacements provides a replacement", () => { + const lines = ["Arrow \u2192 here"]; + const errors = runRule(rule, lines, { + unicodeReplacements: { "\u2192": "->" }, + }, "doc.md"); + const arrowError = errors.find((e) => e.detail && (e.detail.includes("U+2192") || e.detail.includes("→"))); + assert.ok(arrowError, "error for arrow should be reported"); + assert.ok(arrowError.fixInfo, "fixable error should include fixInfo when replacement is configured"); + assert.strictEqual(typeof arrowError.fixInfo.editColumn, "number"); + assert.strictEqual(typeof arrowError.fixInfo.deleteCount, "number"); + assert.strictEqual(arrowError.fixInfo.insertText, "->", "insertText should be the configured replacement"); + }); + + it("does not report fixInfo when no replacement is configured", () => { + const lines = ["Arrow \u2192"]; + const errors = runRule(rule, lines, { unicodeReplacements: {} }, "doc.md"); + assert.ok(errors.length >= 1); + assert.ok(!errors[0].fixInfo, "error should not have fixInfo when unicodeReplacements has no replacement for the character"); + }); + }); + + describe("edge cases", () => { + it("line containing only variation selector after allowed emoji is allowed", () => { + const lines = ["\u263A\uFE00"]; + const errors = runRule(rule, lines, { + allowedPathPatternsEmoji: ["*.md"], + allowedEmoji: ["\u263A"], + }, "doc.md"); + assert.strictEqual(errors.length, 0); + }); + + it("astral character (emoji) reported with code point in detail (4–6 hex digits)", () => { + const lines = ["Smile \u{1F600} here"]; + const errors = runRule(rule, lines, {}, "doc.md"); + assert.ok(errors.length >= 1); + assert.ok(errors.some((e) => /U\+[0-9A-F]{4,6}/i.test(e.detail)), "detail should include code point"); + }); + + it("path matching both unicode and emoji patterns allows unicode when only unicode config set", () => { + const lines = ["Café"]; + const errors = runRule(rule, lines, { + allowedPathPatternsUnicode: ["*.md"], + }, "doc.md"); + assert.strictEqual(errors.length, 0); + }); + }); }); diff --git a/test/markdownlint-rules/document-length.test.js b/test/markdownlint-rules/document-length.test.js index 6fb6ad4..49b0d5e 100644 --- a/test/markdownlint-rules/document-length.test.js +++ b/test/markdownlint-rules/document-length.test.js @@ -15,6 +15,13 @@ function makeLines(n) { } describe("document-length", () => { + it("skips when file path matches excludePathPatterns", () => { + const lines = makeLines(1501); + const config = { excludePathPatterns: ["**/long.md"] }; + const errors = runRule(rule, lines, config, "docs/long.md"); + assert.strictEqual(errors.length, 0); + }); + it("reports no errors when lines.length <= maximum (default 1500)", () => { const lines = makeLines(1500); const errors = runRule(rule, lines); @@ -67,6 +74,29 @@ describe("document-length", () => { assert.ok(errors[0].detail.includes("1500")); }); + it("uses top-level maximum when rule block has no maximum", () => { + const lines = makeLines(11); + const config = { "document-length": {}, maximum: 10 }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].detail.includes("11") && errors[0].detail.includes("10")); + }); + + it("uses rule-level maximum when config has document-length block", () => { + const lines = makeLines(11); + const config = { "document-length": { maximum: 10 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].detail.includes("11") && errors[0].detail.includes("10")); + }); + + it("skips when params.config is undefined (branch coverage)", () => { + const lines = makeLines(1501); + const errors = []; + rule.function({ lines, config: undefined, name: "x.md" }, (e) => errors.push(e)); + assert.strictEqual(errors.length, 1, "no excludePathPatterns so rule runs and reports over limit"); + }); + it("reports error with empty context when first line is undefined (over limit)", () => { const lines = Array(2); lines[1] = "second"; @@ -74,4 +104,48 @@ describe("document-length", () => { assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].context, ""); }); + + describe("HTML comment suppress", () => { + it("reports no error when line 1 is solely the suppress comment (over limit)", () => { + const lines = ["", "second line"]; + const config = { "document-length": { maximum: 1 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("reports no error when line 1 ends with the suppress comment (over limit)", () => { + const lines = ["# Title ", "second line"]; + const config = { "document-length": { maximum: 1 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("reports error when over limit and no suppress comment", () => { + const lines = ["# Title", "second line"]; + const config = { "document-length": { maximum: 1 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + }); + + describe("edge cases", () => { + it("maximum 0 uses default maximum (1500)", () => { + const lines = makeLines(10); + const errors = runRule(rule, lines, { maximum: 0 }); + assert.strictEqual(errors.length, 0); + }); + + it("maximum negative uses default maximum", () => { + const lines = makeLines(10); + const errors = runRule(rule, lines, { maximum: -1 }); + assert.strictEqual(errors.length, 0); + }); + + it("rule-level config block with empty object uses top-level maximum", () => { + const lines = makeLines(1501); + const config = { "document-length": {}, maximum: 1500 }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + }); }); diff --git a/test/markdownlint-rules/fenced-code-under-heading.test.js b/test/markdownlint-rules/fenced-code-under-heading.test.js index af7643d..04ead46 100644 --- a/test/markdownlint-rules/fenced-code-under-heading.test.js +++ b/test/markdownlint-rules/fenced-code-under-heading.test.js @@ -213,4 +213,53 @@ describe("fenced-code-under-heading", () => { const errors = runRule(rule, lines, config); assert.strictEqual(errors.length, 0); }); + + describe("edge cases", () => { + it("minHeadingLevel 3 means H2 does not count as heading above block", () => { + const lines = ["# Doc", "## Section", "```go", "x", "```"]; + const config = { "fenced-code-under-heading": { languages: ["go"], minHeadingLevel: 3 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 3); + assert.ok(errors[0].detail.includes("H3")); + }); + + it("fenced block at line 1 with no heading reports error", () => { + const lines = ["```go", "x", "```"]; + const config = { "fenced-code-under-heading": { languages: ["go"] } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + }); + + it("exclusive: three blocks under one heading report two errors (2nd and 3rd block)", () => { + const lines = [ + "# Doc", + "## Section", + "```go", + "a", + "```", + "```bash", + "b", + "```", + "```text", + "c", + "```", + ]; + const config = { "fenced-code-under-heading": { languages: ["go"], exclusive: true } }; + const errors = runRule(rule, lines, config); + assert.ok(errors.length >= 2, "second and third blocks should be reported"); + const lineNumbers = errors.map((e) => e.lineNumber).sort((a, b) => a - b); + assert.ok(lineNumbers.includes(6)); + assert.ok(lineNumbers.includes(9)); + }); + + it("maxHeadingLevel 2 excludes H3 so block under H3 has no valid heading", () => { + const lines = ["# Doc", "## A", "### B", "```go", "x", "```"]; + const config = { "fenced-code-under-heading": { languages: ["go"], maxHeadingLevel: 2 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 4); + }); + }); }); diff --git a/test/markdownlint-rules/heading-min-words.test.js b/test/markdownlint-rules/heading-min-words.test.js index cf1c191..5a45a4b 100644 --- a/test/markdownlint-rules/heading-min-words.test.js +++ b/test/markdownlint-rules/heading-min-words.test.js @@ -117,4 +117,54 @@ describe("heading-min-words", () => { assert.strictEqual(errors.length, 1); assert.ok(errors[0].detail.includes("word(s)") && errors[0].detail.includes("found")); }); + + describe("HTML comment suppress", () => { + it("reports no error when suppress comment on previous line before single-word heading", () => { + const lines = ["# Doc", "", "## Foo", "Content."]; + const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("reports no error when heading line ends with suppress comment", () => { + const lines = ["# Doc", "## Foo ", "Content."]; + const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("reports error when wrong rule name in comment on previous line", () => { + const lines = ["# Doc", "", "## Foo", "Content."]; + const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 3); + }); + }); + + describe("edge cases", () => { + it("allowList is case-insensitive (Overview matches overview)", () => { + const lines = ["# Doc", "## overview", "Content."]; + const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2, allowList: ["Overview"] } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 0); + }); + + it("applyToLevelsAtOrBelow 1 includes H1 in scope", () => { + const lines = ["# One", "## Two Words", "Content."]; + const config = { "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 1 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + }); + + it("excludePathPatterns skips when path matches exclude even if it matches include", () => { + const lines = ["# Doc", "## Foo", "Content."]; + const config = { + "heading-min-words": { minWords: 2, applyToLevelsAtOrBelow: 2, includePaths: ["**/*.md"], excludePaths: ["**/foo.md"] }, + }; + const errors = runRule(rule, lines, config, "docs/foo.md"); + assert.strictEqual(errors.length, 0, "excluded path should skip rule"); + }); + }); }); diff --git a/test/markdownlint-rules/heading-numbering.test.js b/test/markdownlint-rules/heading-numbering.test.js index d4f5cf4..3c51883 100644 --- a/test/markdownlint-rules/heading-numbering.test.js +++ b/test/markdownlint-rules/heading-numbering.test.js @@ -19,6 +19,13 @@ describe("heading-numbering", () => { assert.strictEqual(errors.length, 0); }); + it("skips when file path matches excludePathPatterns", () => { + const lines = ["# Doc", "## 1. First", "## 4. Skip"]; + const config = { excludePathPatterns: ["**/skip.md"] }; + const errors = runRule(rule, lines, config, "docs/skip.md"); + assert.strictEqual(errors.length, 0); + }); + it("reports no errors for consistent numbering", () => { // Siblings 1., 2.; subsection 2.1. under 2. is valid. const lines = ["# Doc", "## 1. First", "## 2. Second", "### 2.1. Sub"]; @@ -71,10 +78,13 @@ describe("heading-numbering", () => { assert.strictEqual(errors.length, 0); }); - it("reports no errors for unnumbered subsections under numbered section", () => { + it("reports error when parent has numbering but child has no number (first child must be numbered)", () => { const lines = ["# Doc", "## 1. First", "### Sub A", "### Sub B"]; const errors = runRule(rule, lines); - assert.strictEqual(errors.length, 0); + const missingNum = errors.filter((e) => e.detail.includes("no number prefix") || e.detail.includes("numbered")); + assert.ok(missingNum.length >= 2, "should report missing number for both unnumbered children"); + const fixFirst = missingNum.find((e) => e.lineNumber === 3); + assert.ok(fixFirst?.fixInfo?.insertText.startsWith("1.1"), "first child under 1. should get prefix 1.1. or 1.1 "); }); it("reports segment error when root heading has number prefix (exercises numbering root level 1)", () => { @@ -128,4 +138,139 @@ describe("heading-numbering", () => { const errors = runRule(rule, lines, config); assert.strictEqual(errors.length, 0); }); + + it("reports fixInfo for out-of-sequence error (replace prefix with expected)", () => { + const lines = ["# Doc", "## 1. First", "## 2. Second", "## 4. Skip"]; + const errors = runRule(rule, lines); + const seqError = errors.find((e) => e.detail.includes("sequence") || e.detail.includes("expected")); + assert.ok(seqError, "should report sequencing error"); + assert.ok(seqError.fixInfo, "fixable error should include fixInfo"); + assert.strictEqual(typeof seqError.fixInfo.editColumn, "number"); + assert.strictEqual(typeof seqError.fixInfo.deleteCount, "number"); + assert.strictEqual(typeof seqError.fixInfo.insertText, "string"); + assert.ok(seqError.fixInfo.insertText.startsWith("3"), "insertText should be expected prefix (3. or 3 )"); + }); + + it("reports fixInfo for missing number prefix (insert expected 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"); + assert.ok(missingNum.fixInfo, "fixable error should include fixInfo"); + assert.strictEqual(missingNum.fixInfo.editColumn, 4, "editColumn after ## "); + assert.strictEqual(missingNum.fixInfo.deleteCount, 0, "insert only"); + assert.ok(missingNum.fixInfo.insertText.startsWith("3"), "insertText should be expected prefix for 4th sibling (3. or 3 )"); + }); + + it("reports fixInfo for wrong segment count (replace with expected prefix)", () => { + 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"); + assert.ok(segmentErr.fixInfo, "fixable error should include fixInfo"); + assert.strictEqual(typeof segmentErr.fixInfo.editColumn, "number"); + assert.ok(segmentErr.fixInfo.deleteCount > 0); + assert.ok(segmentErr.fixInfo.insertText.includes("2.1"), "insertText should be correct prefix (e.g. 2.1. )"); + }); + + it("reports fixInfo for period style inconsistency (add or remove period)", () => { + 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 error"); + assert.ok(periodErr.fixInfo, "fixable error should include fixInfo"); + assert.strictEqual(typeof periodErr.fixInfo.editColumn, "number"); + assert.ok(periodErr.fixInfo.insertText.startsWith("3"), "insertText should be same number with section period style"); + }); + + it("reports fixInfo for period style when section uses no period (remove period)", () => { + const lines = ["# Doc", "## 1 First", "## 2 Second", "## 3. With 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 error"); + assert.ok(periodErr.fixInfo, "fixable error should include fixInfo"); + assert.ok(periodErr.fixInfo.insertText.startsWith("3") && !periodErr.fixInfo.insertText.startsWith("3."), "insertText should be 3 without trailing period"); + }); + + it("getExpectedPrefixForNewHeading returns prefix for first child under numbered parent (no sibling)", () => { + const { getExpectedPrefixForNewHeading } = rule; + const lines = ["# Doc", "## 1. First", "Content", "## 2. Second"]; + const prefixAt3 = getExpectedPrefixForNewHeading(lines, 3, 3); + assert.ok(prefixAt3.startsWith("1.1"), "insert H3 after 1. First should get 1.1. or 1.1 "); + assert.ok(prefixAt3.endsWith(" "), "prefix should end with space"); + }); + + it("does not report fixInfo for maxSegmentValue or maxHeadingLevel errors", () => { + const linesMaxSeg = ["# Doc", "## 1. First", "## 21. Too big"]; + const config = { "heading-numbering": { maxSegmentValue: 20 } }; + const errorsMaxSeg = runRule(rule, linesMaxSeg, config); + const segErr = errorsMaxSeg.find((e) => e.detail.includes("exceeds maximum")); + assert.ok(segErr, "should report max segment value error"); + assert.ok(!segErr.fixInfo, "maxSegmentValue error should not have fixInfo"); + + const linesMaxLevel = ["# Doc", "## 1. First", "###### Too deep"]; + const configLevel = { "heading-numbering": { maxHeadingLevel: 5 } }; + const errorsMaxLevel = runRule(rule, linesMaxLevel, configLevel); + const levelErr = errorsMaxLevel.find((e) => e.detail.includes("deeper than maximum")); + assert.ok(levelErr, "should report max heading level error"); + assert.ok(!levelErr.fixInfo, "maxHeadingLevel error should not have fixInfo"); + }); + + it("runs when maxHeadingLevel is set but no headings have numbering (branch coverage)", () => { + const lines = ["# Title", "## Section", "### Subsection"]; + const config = { "heading-numbering": { maxHeadingLevel: 2 } }; + const errors = runRule(rule, lines, config); + const levelErr = errors.find((e) => e.detail.includes("deeper than maximum")); + assert.ok(levelErr, "H3 should exceed maxHeadingLevel 2"); + assert.strictEqual(levelErr.lineNumber, 3); + }); + + it("getExpectedPrefixForNewHeading returns empty when section does not use numbering", () => { + const { getExpectedPrefixForNewHeading } = rule; + const lines = ["# Doc", "## Unnumbered Section", "Content here"]; + const prefix = getExpectedPrefixForNewHeading(lines, 3, 2); + assert.strictEqual(prefix, "", "section has no numbering so prefix should be empty"); + }); + + it("reports no error for heading when suppress comment on previous line (line-level override)", () => { + const lines = ["# Doc", "## 1. First", "## 2. Second", "", "## 4. Skip", "## 5. Next"]; + const errors = runRule(rule, lines); + const errorOnLine5 = errors.filter((e) => e.lineNumber === 5); + assert.strictEqual(errorOnLine5.length, 0, "line 5 (## 4. Skip) should be suppressed by comment on line 4"); + }); + + it("maxSegmentValueMinLevel custom value (branch coverage for readMaxSegmentValueOpts)", () => { + const lines = ["# Doc", "## 1. First", "### 1.1. Sub", "#### 30. Deep"]; + const config = { "heading-numbering": { maxSegmentValue: 20, maxSegmentValueMinLevel: 2 } }; + const errors = runRule(rule, lines, config); + const maxErr = errors.find((e) => e.detail.includes("exceeds maximum")); + assert.ok(maxErr, "segment 30 should exceed 20 when level is in scope"); + }); + + describe("edge cases", () => { + it("getExpectedPrefixForNewHeading at line before any heading returns sensible prefix", () => { + const { getExpectedPrefixForNewHeading } = rule; + const lines = ["No heading yet.", "More text.", "## 1. First"]; + const prefixAt1 = getExpectedPrefixForNewHeading(lines, 1, 2); + assert.ok(typeof prefixAt1 === "string"); + assert.ok(prefixAt1.length >= 0); + }); + + it("mixed 0-based and 1-based siblings in same section reports sequence or style error", () => { + const lines = ["# Doc", "## 0. Zero", "## 1. One", "## 2. Two"]; + const errors = runRule(rule, lines); + const periodErr = errors.find((e) => e.detail.includes("period") || e.detail.includes("sequence")); + assert.ok(periodErr != null || errors.length === 0, "mixed 0. and 1. style may report period/sequence"); + }); + + it("maxSegmentValueMaxLevel excludes deeper levels from max value check", () => { + const lines = ["# Doc", "## 1. First", "### 1.1. Sub", "#### 25. Deep"]; + const config = { "heading-numbering": { maxSegmentValue: 20, maxSegmentValueMaxLevel: 3 } }; + const errors = runRule(rule, lines, config); + const maxValueErr = errors.find( + (e) => e.lineNumber === 4 && e.detail.includes("exceeds maximum") + ); + assert.ok(!maxValueErr, "level 4 segment 25 should not get max value error when maxSegmentValueMaxLevel is 3"); + }); + }); }); diff --git a/test/markdownlint-rules/heading-title-case.test.js b/test/markdownlint-rules/heading-title-case.test.js index 78a47da..a667524 100644 --- a/test/markdownlint-rules/heading-title-case.test.js +++ b/test/markdownlint-rules/heading-title-case.test.js @@ -20,14 +20,37 @@ describe("heading-title-case", () => { assert.strictEqual(errors.length, 0); }); + it("skips when file path matches excludePathPatterns", () => { + const lines = ["# all lowercase wrong"]; + const config = { excludePathPatterns: ["**/excluded.md"] }; + const errors = runRule(rule, lines, config, "path/excluded.md"); + assert.strictEqual(errors.length, 0); + }); + + it("skips when file path matches excludePathPatterns (rule-level config)", () => { + const lines = ["# all wrong"]; + const config = { "heading-title-case": { excludePathPatterns: ["**"] } }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + + it("reports filename error and skips title-case error for same word (continue branch)", () => { + const lines = ["# readme.md"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].detail.includes("backticks") || errors[0].detail.includes("File name")); + }); + it("reports error when middle word should be lowercase", () => { - // Default lowercase list includes "and"; "And" in the middle is invalid. + // Default lowercase list includes "is" and "and"; "Is" and "And" in the middle are invalid. const lines = ["# This Is And Valid"]; const errors = runRule(rule, lines); - assert.strictEqual(errors.length, 1); + assert.strictEqual(errors.length, 2, "both 'Is' and 'And' should be reported"); 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.strictEqual(errors[1].lineNumber, 1); + const details = errors.map((e) => e.detail); + assert.ok(details.some((d) => d.includes("Is") && d.includes("lowercase")), "one error for 'Is'"); + assert.ok(details.some((d) => d.includes("And") && d.includes("lowercase")), "one error for 'And'"); assert.ok(Array.isArray(errors[0].range) && errors[0].range.length === 2, "error should include range [column, length] for the violating word"); }); @@ -60,7 +83,7 @@ describe("heading-title-case", () => { 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.length, 1, "only 'And' reported ('the' is already lowercase)"); assert.strictEqual(errors[0].lineNumber, 1); assert.ok(Array.isArray(errors[0].range) && errors[0].range.length === 2); const [col, len] = errors[0].range; @@ -73,15 +96,16 @@ describe("heading-title-case", () => { 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")); + assert.strictEqual(errors.length, 2, "both 'getting' (first) and 'started' (last) should be capitalized"); + const details = errors.map((e) => e.detail); + assert.ok(details.some((d) => d.includes("getting") && (d.includes("capitalized") || d.includes("first")))); + assert.ok(details.some((d) => d.includes("started") && (d.includes("capitalized") || d.includes("last")))); }); 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.strictEqual(errors.length, 1, "only 'practice' reported ('in' is already lowercase)"); assert.ok(errors[0].detail.includes("practice")); assert.ok(errors[0].detail.includes("capitalized") || errors[0].detail.includes("last")); }); @@ -112,6 +136,18 @@ describe("heading-title-case", () => { assert.ok(errors[0].detail.includes("the") && errors[0].detail.includes("capitalized")); }); + it("allows single letter after 'Phase' as phase label (e.g. Phase A, Phase B)", () => { + const lines = ["### Phase A: Fixable Rules and Scripts (One-Time)"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0, "Phase A should not be flagged; single letter after 'Phase' is a label"); + }); + + it("allows single letter after other label-parent words (Step B, Appendix A, Type A)", () => { + const lines = ["## Step B: Do Something", "## Appendix A: References", "## Type A and Type B"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0, "single letter after Step/Appendix/Type is a label"); + }); + it("reports no errors for heading that is only inline code (words.length === 0)", () => { const lines = ["## `code only`"]; const errors = runRule(rule, lines); @@ -143,4 +179,175 @@ describe("heading-title-case", () => { const errors = runRule(rule, lines); assert.strictEqual(errors.length, 0); }); + + describe("fixInfo (auto-fix)", () => { + it("reports fixInfo with editColumn, deleteCount, insertText for middle-word lowercase violation", () => { + const lines = ["# The Cat And the Hat"]; + const errors = runRule(rule, lines); + const andError = errors.find((e) => e.detail && e.detail.includes("And")); + assert.ok(andError, "error for 'And' should be reported"); + assert.ok(andError.fixInfo, "fixable error should include fixInfo"); + assert.strictEqual(typeof andError.fixInfo.editColumn, "number"); + assert.strictEqual(typeof andError.fixInfo.deleteCount, "number"); + assert.strictEqual(typeof andError.fixInfo.insertText, "string"); + assert.strictEqual(andError.fixInfo.insertText, "and", "insertText should correct 'And' to lowercase 'and'"); + }); + + it("reports fixInfo with insertText capitalizing last word", () => { + const lines = ["## Using Tools in practice"]; + const errors = runRule(rule, lines); + const practiceError = errors.find((e) => e.detail && e.detail.includes("practice")); + assert.ok(practiceError, "error for 'practice' should be reported"); + assert.ok(practiceError.fixInfo); + assert.strictEqual(practiceError.fixInfo.insertText, "Practice", "insertText should capitalize 'practice' to 'Practice'"); + }); + + it("reports fixInfo with insertText capitalizing first word", () => { + const lines = ["## getting started"]; + const errors = runRule(rule, lines); + const gettingError = errors.find((e) => e.detail && e.detail.includes("getting")); + assert.ok(gettingError, "error for 'getting' should be reported"); + assert.ok(gettingError.fixInfo); + assert.strictEqual(gettingError.fixInfo.insertText, "Getting", "insertText should capitalize 'getting' to 'Getting'"); + }); + + it("reports fixInfo for hyphenated segment (correct segment only)", () => { + const lines = ["# One-stop Shop"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo); + assert.strictEqual(errors[0].fixInfo.insertText, "Stop", "insertText should capitalize segment 'stop' to 'Stop'"); + }); + + it("suggests backticks for file name instead of case change", () => { + const lines = ["## See README.md for more"]; + const errors = runRule(rule, lines); + const fileError = errors.find((e) => e.detail && e.detail.includes("backticks")); + assert.ok(fileError, "should report file name should be in backticks"); + assert.ok(fileError.fixInfo); + assert.strictEqual(fileError.fixInfo.insertText, "`README.md`"); + assert.ok(fileError.detail.includes("README.md")); + }); + + it("suggests backticks for Makefile (no extension)", () => { + const lines = ["## Edit the Makefile"]; + const errors = runRule(rule, lines); + const fileError = errors.find((e) => e.detail && e.detail.includes("backticks")); + assert.ok(fileError); + assert.strictEqual(fileError.fixInfo.insertText, "`Makefile`"); + }); + + it("does not treat numbering like 1.2 or 0.1 as file name", () => { + const lines = ["#### 1.3 Skip 1.2 (Expected 1.2)"]; + const errors = runRule(rule, lines); + const backtickErrors = errors.filter((e) => e.detail && e.detail.includes("backticks")); + assert.strictEqual(backtickErrors.length, 0, "numbering segments 1.2 etc. should not trigger file-name backticks"); + }); + + it("skips punctuation-only token when checking for filename (core empty)", () => { + const lines = ["## See README.md ... and more"]; + const errors = runRule(rule, lines); + const backtickErrors = errors.filter((e) => e.detail && e.detail.includes("backticks")); + assert.strictEqual(backtickErrors.length, 1, "only README.md gets backticks; ... is skipped"); + assert.strictEqual(backtickErrors[0].fixInfo.insertText, "`README.md`"); + }); + + it("suggests backticks for filenames with leading/trailing punctuation (e.g. in parens)", () => { + const line = "### `security/detect-non-literal-regexp` (utils.js, allow-custom-anchors.js)"; + const lines = [line]; + const errors = runRule(rule, lines); + const backtickErrors = errors.filter((e) => e.detail && e.detail.includes("backticks")); + assert.strictEqual(backtickErrors.length, 2, "utils.js and allow-custom-anchors.js should get backtick suggestion"); + const utilsFix = backtickErrors.find((e) => e.fixInfo.insertText.includes("utils.js")); + const anchorsFix = backtickErrors.find((e) => e.fixInfo.insertText.includes("allow-custom-anchors.js")); + assert.ok(utilsFix, "utils.js fix present"); + assert.strictEqual(utilsFix.fixInfo.insertText, "(`utils.js`,", "preserve leading ( and trailing ,"); + assert.ok(anchorsFix, "allow-custom-anchors.js fix present"); + assert.strictEqual(anchorsFix.fixInfo.insertText, "`allow-custom-anchors.js`)", "preserve trailing )"); + }); + + it("fixInfo targets correct word when heading has backticks and parentheses (inline code)", () => { + const line = "## Python: `# noqa: E402` (module level import not at top of file)"; + const lines = [line]; + const errors = runRule(rule, lines); + assert.ok(errors.length >= 1, "should report at least one title-case error"); + const moduleError = errors.find((e) => e.fixInfo && e.fixInfo.insertText === "(Module"); + assert.ok(moduleError, "should report fix for '(module' -> '(Module' (subphrase start after paren)"); + assert.strictEqual(moduleError.fixInfo.editColumn, 27, "editColumn must point to '(' in '(module' (after ## Python: `# noqa: E402` )"); + assert.strictEqual(moduleError.fixInfo.deleteCount, 7); + const wordInLine = line.slice(moduleError.fixInfo.editColumn - 1, moduleError.fixInfo.editColumn - 1 + moduleError.fixInfo.deleteCount); + assert.strictEqual(wordInLine, "(module", "range must span the word (module"); + }); + }); + + describe("applyTitleCase (export)", () => { + it("handles hyphenated word with punctuation-only segment (coverage)", () => { + assert.strictEqual(typeof rule.applyTitleCase, "function"); + const result = rule.applyTitleCase("Test---Here"); + assert.strictEqual(result, "Test---Here", "punctuation-only segments preserved"); + }); + + it("accepts lowercaseWords as Set (coverage)", () => { + const result = rule.applyTitleCase("use and through", { + lowercaseWords: new Set(["and", "through"]), + }); + assert.strictEqual(result, "Use and Through"); + }); + + it("lowercaseWordsReplaceDefault: true uses only config list (coverage)", () => { + const result = rule.applyTitleCase("a and the b", { + lowercaseWords: ["and", "the"], + lowercaseWordsReplaceDefault: true, + }); + assert.strictEqual(result, "A and the B"); + }); + + it("preserves single letter after 'Phase' as phase label", () => { + const result = rule.applyTitleCase("Phase A: Fixable Rules and Scripts (One-Time)"); + assert.strictEqual(result, "Phase A: Fixable Rules and Scripts (One-Time)", "Phase A label stays capitalized"); + }); + + it("preserves single letter after other label-parent words (Step A, Appendix A)", () => { + assert.strictEqual(rule.applyTitleCase("Step A: Setup"), "Step A: Setup"); + assert.strictEqual(rule.applyTitleCase("Appendix A: Glossary"), "Appendix A: Glossary"); + }); + + it("returns empty or whitespace as-is (words.length === 0 branch)", () => { + assert.strictEqual(rule.applyTitleCase(""), ""); + assert.strictEqual(rule.applyTitleCase(" "), " "); + }); + }); + + it("runs with config undefined (branch coverage)", () => { + const lines = ["# Valid Title Here"]; + const errors = runRule(rule, lines, undefined); + assert.strictEqual(errors.length, 0); + }); + + describe("edge cases (applyTitleCase export)", () => { + it("applyTitleCase with empty string returns empty string", () => { + assert.strictEqual(rule.applyTitleCase(""), ""); + }); + + it("applyTitleCase with null or undefined (edge case: may throw or return)", () => { + try { + const rNull = rule.applyTitleCase(null); + assert.ok(rNull === null || typeof rNull === "string", "null input: return null or string"); + } catch (err) { + assert.ok(err instanceof Error, "null input may throw in current implementation"); + } + try { + const rUndef = rule.applyTitleCase(undefined); + assert.ok(rUndef === undefined || typeof rUndef === "string", "undefined input: return undefined or string"); + } catch (err) { + assert.ok(err instanceof Error, "undefined input may throw in current implementation"); + } + }); + + it("heading with only numbers and symbols has no title-case violation", () => { + const lines = ["## 1.2.3"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + }); }); diff --git a/test/markdownlint-rules/no-duplicate-headings-normalized.test.js b/test/markdownlint-rules/no-duplicate-headings-normalized.test.js index cafd45a..fde5039 100644 --- a/test/markdownlint-rules/no-duplicate-headings-normalized.test.js +++ b/test/markdownlint-rules/no-duplicate-headings-normalized.test.js @@ -19,6 +19,38 @@ describe("no-duplicate-headings-normalized", () => { assert.strictEqual(errors.length, 0); }); + it("skips when file path matches excludePathPatterns", () => { + const lines = ["# Introduction", "## Introduction"]; + const config = { excludePathPatterns: ["**/excluded.md"] }; + const errors = runRule(rule, lines, config, "docs/excluded.md"); + assert.strictEqual(errors.length, 0); + }); + + it("skips heading that normalizes to empty (no key)", () => { + const lines = ["## ", "## Section", "content"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips multiple headings that normalize to empty (continue branch)", () => { + const lines = ["## ", "### ", "## Real", "content"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("single heading that normalizes to empty hits continue (branch coverage)", () => { + const lines = ["## ", "body"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips when file path matches excludePathPatterns (rule-level config)", () => { + const lines = ["# A", "## A"]; + const config = { "no-duplicate-headings-normalized": { excludePathPatterns: ["**"] } }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + it("reports error for duplicate normalized title", () => { // Same text at different levels still normalizes to the same key. const lines = ["# Introduction", "Content.", "## Introduction"]; @@ -53,4 +85,35 @@ describe("no-duplicate-headings-normalized", () => { assert.ok(errors.every((e) => e.detail.includes("Overview") || e.detail.includes("overview"))); assert.ok(errors.some((e) => e.detail.includes("line 1"))); }); + + it("suppress comment on previous line skips duplicate error for that line (branch coverage)", () => { + const lines = ["# A", "## A", "", "## A"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1, "only first duplicate reported; third suppressed by comment"); + assert.strictEqual(errors[0].lineNumber, 2); + }); + + it("uses top-level config when rule key absent (branch coverage)", () => { + const lines = ["# Same", "## Same"]; + const config = { "other-rule": {}, default: true }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + + describe("edge cases", () => { + it("duplicate with different casing and whitespace normalizes to same key", () => { + const lines = ["## Introduction ", "Content.", "## INTRODUCTION"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 3); + assert.ok(errors[0].detail.toLowerCase().includes("introduction")); + }); + + it("config from rule-level key no-duplicate-headings-normalized", () => { + const lines = ["# A", "## A"]; + const config = { "no-duplicate-headings-normalized": { excludePathPatterns: [] } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + }); }); diff --git a/test/markdownlint-rules/no-empty-heading.test.js b/test/markdownlint-rules/no-empty-heading.test.js index dc3273b..7172892 100644 --- a/test/markdownlint-rules/no-empty-heading.test.js +++ b/test/markdownlint-rules/no-empty-heading.test.js @@ -98,6 +98,13 @@ describe("no-empty-heading", () => { assert.strictEqual(errors[0].lineNumber, 2); }); + it("reports error for H2 with only multi-line HTML comment (lines do not count as content)", () => { + const lines = ["# Doc", "## Empty", "", "## Next", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 2); + }); + it("reports no error when section has only the suppress comment (no-empty-heading allow)", () => { const lines = ["# Doc", "## Empty", "", "## Next", "Content."]; const errors = runRule(rule, lines); @@ -129,6 +136,12 @@ describe("no-empty-heading", () => { assert.strictEqual(errors.length, 0); }); + it("reports no error when suppress comment on line before empty heading (line-level override)", () => { + const lines = ["# Doc", "", "## Empty", "## Next", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + it("reports error when suppress comment is on same line as another comment (must be on its own line)", () => { const lines = ["# Doc", "## Empty", " ", "## Next", "Content."]; const errors = runRule(rule, lines); @@ -296,4 +309,28 @@ describe("no-empty-heading", () => { const errors = runRule(rule, lines, config); assert.strictEqual(errors.length, 0); }); + + describe("edge cases", () => { + it("unclosed multi-line HTML comment at end of section does not count as content", () => { + const lines = ["# Doc", "## Empty", "", "", "## Section"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + it("reports no errors for badge lines under h1", () => { const lines = [ "# Repo", @@ -54,6 +60,49 @@ describe("no-h1-content", () => { assert.strictEqual(errors.length, 0); }); + it("reports no errors for reference-style badge lines under h1", () => { + const lines = [ + "# Repo", + "[![Docs Check][badge-docs-check]][workflow-docs-check]", + "[![Go CI][badge-go-ci]][workflow-go-ci]", + "[![License][badge-license]][license-file]", + "", + "## Section", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports no errors for multiple reference-style badges on one line under h1", () => { + const lines = [ + "# Repo", + "[![Docs Check][badge-docs-check]][workflow-docs-check] [![Go CI][badge-go-ci]][workflow-go-ci] [![License][badge-license]][license-file]", + "## Section", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports no errors for mixed inline and reference-style badges on one line under h1", () => { + const lines = [ + "# Repo", + "[![CI](https://example.com/ci.svg)](https://example.com) [![License][badge-license]][license-file]", + "## Section", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports no errors for reference-style badge line with leading and trailing spaces under h1", () => { + const lines = [ + "# Repo", + " [![Docs Check][badge-docs-check]][workflow-docs-check] ", + "## Section", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + it("reports no errors for empty content under h1", () => { const lines = ["# Title", "", "## First"]; const errors = runRule(rule, lines); @@ -68,6 +117,12 @@ describe("no-h1-content", () => { assert.ok(errors[0].detail.includes("table of contents")); }); + it("reports no error when suppress comment on previous line (line-level override)", () => { + const lines = ["# Title", "", "This is not allowed.", "## Section"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + it("reports error for code block under h1", () => { const lines = ["# Title", "```", "code", "```", "## Next"]; const errors = runRule(rule, lines); @@ -100,4 +155,25 @@ describe("no-h1-content", () => { const errors = runRule(rule, lines, config, "md_test_files/foo.md"); assert.strictEqual(errors.length, 1); }); + + describe("edge cases", () => { + it("TOC link with space before hash [Text]( #anchor ) is not valid TOC item", () => { + const lines = ["# Doc", "- [One]( #one)", "## One"]; + const errors = runRule(rule, lines); + assert.ok(errors.length >= 1, "line with space in link may not match RE_TOC_LIST_ITEM"); + }); + + it("H1 as last line (endLine equals lines.length) has no content under it", () => { + const lines = ["## First", "Content.", "# Only H1"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0, "no content under H1 at end"); + }); + + it("fenced code block opening line under H1 is reported as disallowed content", () => { + const lines = ["# Title", "```", "code", "```", "## Next"]; + const errors = runRule(rule, lines); + assert.ok(errors.length >= 1); + assert.strictEqual(errors[0].lineNumber, 2); + }); + }); }); diff --git a/test/markdownlint-rules/no-heading-like-lines.test.js b/test/markdownlint-rules/no-heading-like-lines.test.js index 150ace9..218ef6a 100644 --- a/test/markdownlint-rules/no-heading-like-lines.test.js +++ b/test/markdownlint-rules/no-heading-like-lines.test.js @@ -8,9 +8,14 @@ const { describe, it } = require("node:test"); const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); const rule = require("../../markdownlint-rules/no-heading-like-lines.js"); const { runRule } = require("./run-rule.js"); +const RULES_DIR = path.join(__dirname, "..", "..", "markdownlint-rules"); + describe("no-heading-like-lines", () => { it("reports no errors for normal text", () => { const lines = ["# Real heading", "Some **bold** text.", ""]; @@ -28,6 +33,12 @@ describe("no-heading-like-lines", () => { assert.ok(errors[0].detail.includes("bold") && errors[0].detail.includes("colon"), "detail should describe the matched pattern"); }); + it("reports no error when suppress comment on previous line (line-level override)", () => { + const lines = ["", "**Summary:**", "Content here."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + it("reports error for 1. **Text** style", () => { // Numbered list with bold text looks like a heading. const lines = ["1. **Introduction**", "Content."]; @@ -37,9 +48,284 @@ describe("no-heading-like-lines", () => { assert.ok(errors[0].detail.includes("numbered") || errors[0].detail.includes("bold"), "detail should describe the matched pattern"); }); + it("reports error for MD036-style **Introduction** (whole line bold)", () => { + const lines = ["**Introduction**", "Content here."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.ok(errors[0].detail.includes("bold only") || errors[0].detail.includes("whole line"), "detail should describe whole-line bold"); + }); + + it("reports error for MD036-style *Note* (whole line italic)", () => { + const lines = ["*Note*", "Content here."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.ok(errors[0].detail.includes("italic only") || errors[0].detail.includes("whole line"), "detail should describe whole-line italic"); + }); + + it("does not report **Summary.** when content ends with punctuation (punctuationMarks)", () => { + const lines = ["**Summary.**", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0, "whole-line bold ending with . should be skipped by default punctuationMarks"); + }); + + it("does not report *Note.* when content ends with punctuation", () => { + const lines = ["*Note.*", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports **Summary.** when punctuationMarks is empty", () => { + const lines = ["**Summary.**", "Content."]; + const config = { "no-heading-like-lines": { punctuationMarks: "" } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "Summary."); + }); + it("ignores empty lines", () => { const lines = ["", " ", ""]; const errors = runRule(rule, lines); assert.strictEqual(errors.length, 0); }); + + it("skips rule when file path matches excludePathPatterns", () => { + const lines = ["**Summary:**", "Content."]; + const config = { "no-heading-like-lines": { excludePathPatterns: ["**/README.md", "docs/**"] } }; + const errorsMatch = runRule(rule, lines, config, "project/README.md"); + assert.strictEqual(errorsMatch.length, 0, "matching path should be excluded"); + const errorsNoMatch = runRule(rule, lines, config, "project/src/guide.md"); + assert.strictEqual(errorsNoMatch.length, 1, "non-matching path should report error"); + }); + + it("skips when file path matches excludePathPatterns (top-level config)", () => { + const lines = ["**Summary:**", "Content."]; + const config = { excludePathPatterns: ["**"] }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + + it("uses default heading level 2 when defaultHeadingLevel is invalid", () => { + const lines = ["**Summary:**", "Content."]; + const config = { "no-heading-like-lines": { convertToHeading: true, defaultHeadingLevel: 0 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("## "), "invalid defaultHeadingLevel falls back to 2"); + }); + + it("skips when file path matches excludePathPatterns (rule-level config)", () => { + const lines = ["**Summary:**", "Content."]; + const config = { "no-heading-like-lines": { excludePathPatterns: ["**"] } }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 0); + }); + + describe("fixInfo (default stripEmphasis)", () => { + it("provides fixInfo for **Summary:** with editColumn 1, deleteCount line length, insertText plain", () => { + const lines = ["**Summary:**", "Content."]; + const config = {}; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.editColumn, 1); + assert.strictEqual(errors[0].fixInfo.deleteCount, "**Summary:**".length); + assert.strictEqual(errors[0].fixInfo.insertText, "Summary:"); + }); + + it("provides fixInfo for 1. **Introduction** with insertText Introduction", () => { + const lines = ["1. **Introduction**", "Content."]; + const errors = runRule(rule, lines, {}); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "Introduction"); + }); + + it("provides fixInfo for **Introduction** (whole line bold) with insertText Introduction", () => { + const lines = ["**Introduction**", "Content."]; + const errors = runRule(rule, lines, {}); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "Introduction"); + }); + + it("provides fixInfo for *Note* (whole line italic) with insertText Note", () => { + const lines = ["*Note*", "Content."]; + const errors = runRule(rule, lines, {}); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "Note"); + }); + }); + + describe("fixInfo (convertToHeading: true)", () => { + const convertConfig = { "no-heading-like-lines": { convertToHeading: true } }; + + it("suggests ### when preceded by ## Section and title is AP title-cased", () => { + const lines = ["## Section", "**The Quick Brown:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("### "), "insertText should be ### ..."); + assert.ok(errors[0].fixInfo.insertText.includes("The Quick Brown"), "minor words lowercase in middle"); + }); + + it("suggests ## when no preceding heading (default level 2)", () => { + const lines = ["**Title:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("## "), "insertText should be ## ..."); + assert.ok(errors[0].fixInfo.insertText.includes("Title")); + }); + + it("invalid defaultHeadingLevel falls back to level 2", () => { + const lines = ["**Title:**", "Content."]; + const config = { "no-heading-like-lines": { convertToHeading: true, defaultHeadingLevel: 0 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("## "), "invalid defaultHeadingLevel should fall back to ##"); + }); + + it("valid defaultHeadingLevel 3 suggests ### (getContextLevel branch)", () => { + const lines = ["**Title:**", "Content."]; + const config = { "no-heading-like-lines": { convertToHeading: true, defaultHeadingLevel: 3 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("### "), "defaultHeadingLevel 3 should suggest ###"); + }); + + it("suggests correct level and title when document has numbered headings", () => { + const lines = ["## 1. First", "### 1.1 Sub", "**New Subsection:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + const insertText = errors[0].fixInfo.insertText; + assert.ok(insertText.startsWith("#### "), "should be #### (one below ###)"); + assert.ok(insertText.includes("New Subsection"), "title should be present"); + }); + + it("suggests number prefix for first child under numbered parent (no sibling)", () => { + const lines = ["# Doc", "## 1. Parent", "**First child:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + const insertText = errors[0].fixInfo.insertText; + assert.ok(insertText.startsWith("### "), "should be ### (one below ##)"); + assert.ok(insertText.includes("1.1"), "should include prefix 1.1. or 1.1 (first child under 1.)"); + assert.ok(insertText.includes("First Child"), "title should be AP title-cased"); + }); + + it("suggests no number prefix when section has no numbering", () => { + const lines = ["## Section", "**Subsection:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + const insertText = errors[0].fixInfo.insertText; + assert.ok(insertText.startsWith("### "), "should be ###"); + assert.ok(insertText.includes("Subsection") && !insertText.match(/\d+\./), "no number prefix"); + }); + + it("insertText ends with \\n when heading-like line is followed by non-blank line (blank line after heading)", () => { + const lines = ["## Section", "**Sub:**", "Next paragraph."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.endsWith("\n"), "should add newline so one blank line before next content"); + }); + + it("insertText does not end with \\n when followed by blank line", () => { + const lines = ["## Section", "**Sub:**", ""]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(!errors[0].fixInfo.insertText.endsWith("\n"), "no extra newline when next line blank"); + }); + + it("insertText does not end with \\n at end of file", () => { + const lines = ["## Section", "**Sub:**"]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(!errors[0].fixInfo.insertText.endsWith("\n"), "no extra newline at EOF"); + }); + + it("fixedHeadingLevel 4 suggests ####", () => { + const lines = ["## Section", "**Sub:**"]; + const config = { "no-heading-like-lines": { convertToHeading: true, fixedHeadingLevel: 4 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("#### "), "should start with ####"); + }); + + it("AP title case: first/last capitalized", () => { + const lines = ["**getting started:**", "Content."]; + const errors = runRule(rule, lines, convertConfig); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Getting Started"), "first and last word capitalized"); + }); + }); + + describe("edge cases", () => { + it("suggested level capped at 6 when last heading is ######", () => { + const lines = ["###### Deep", "**Deeper:**"]; + const config = { "no-heading-like-lines": { convertToHeading: true } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("###### "), "level should be capped at 6"); + }); + + it("fixedHeadingLevel 0 falls back to default level", () => { + const lines = ["**Title:**", "Content."]; + const config = { "no-heading-like-lines": { convertToHeading: true, fixedHeadingLevel: 0 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("## "), "invalid fixedHeadingLevel should fall back to ##"); + }); + + it("fixedHeadingLevel 7 falls back to default level", () => { + const lines = ["**Title:**", "Content."]; + const config = { "no-heading-like-lines": { convertToHeading: true, fixedHeadingLevel: 7 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("## "), "fixedHeadingLevel > 6 should fall back"); + }); + + it("reports **Text:** with leading and trailing spaces (trimmed before match)", () => { + const lines = [" **Summary:** ", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "Summary:"); + }); + + it("bold colon only **:** does not match **.*:** (needs content)", () => { + const lines = ["**:**", "Content."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + }); + + describe("optional dependencies (graceful degradation)", () => { + it("rule works without heading-title-case and heading-numbering (stripEmphasis fix)", () => { + const tmpDir = path.join(__dirname, "..", "..", "tmp-no-heading-like-lines-standalone"); + fs.mkdirSync(tmpDir, { recursive: true }); + try { + fs.copyFileSync(path.join(RULES_DIR, "utils.js"), path.join(tmpDir, "utils.js")); + fs.copyFileSync( + path.join(RULES_DIR, "no-heading-like-lines.js"), + path.join(tmpDir, "no-heading-like-lines.js") + ); + const script = ` + const rule = require("./no-heading-like-lines.js"); + const errors = []; + rule.function({ lines: ["**Hi:**", "content"], config: {} }, (e) => errors.push(e)); + console.log(JSON.stringify(errors.length > 0 ? errors[0].fixInfo : null)); + `; + const result = spawnSync( + process.execPath, + ["-e", script], + { cwd: tmpDir, encoding: "utf8", maxBuffer: 10 * 1024 } + ); + assert.strictEqual(result.status, 0, result.stderr || result.error); + const fixInfo = JSON.parse(result.stdout.trim()); + assert.ok(fixInfo, "should report one error with fixInfo"); + assert.strictEqual(fixInfo.insertText, "Hi:", "stripEmphasis when optional deps missing"); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true }); + } catch { + // ignore + } + } + }); + }); }); diff --git a/test/markdownlint-rules/one-sentence-per-line.test.js b/test/markdownlint-rules/one-sentence-per-line.test.js new file mode 100644 index 0000000..5608ee0 --- /dev/null +++ b/test/markdownlint-rules/one-sentence-per-line.test.js @@ -0,0 +1,304 @@ +"use strict"; + +/** + * Unit tests for one-sentence-per-line: enforce one sentence per line in prose + * and list content; fixInfo splits at the first sentence boundary. + */ + +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const rule = require("../../markdownlint-rules/one-sentence-per-line.js"); +const { runRule } = require("./run-rule.js"); + +describe("one-sentence-per-line", () => { + it("reports no errors for single-sentence lines", () => { + const lines = ["One sentence here.", "Another line.", ""]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports error for two sentences on one line (paragraph)", () => { + const lines = ["First sentence. Second sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.ok(errors[0].detail.includes("one sentence per line") || errors[0].detail.includes("multiple sentences")); + assert.ok(errors[0].fixInfo, "fixable rule should provide fixInfo"); + assert.strictEqual(typeof errors[0].fixInfo.editColumn, "number"); + assert.strictEqual(typeof errors[0].fixInfo.deleteCount, "number"); + assert.ok(errors[0].fixInfo.insertText.startsWith("\n"), "insertText should start with newline + indent"); + }); + + it("reports no error when suppress comment on previous line (line-level override)", () => { + const lines = ["", "First sentence. Second sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("reports error for two sentences in numbered list item", () => { + const lines = ["1. First sentence. Second sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Second sentence.")); + }); + + it("reports error for two sentences in bullet list item", () => { + const lines = ["- First sentence. Second sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Second sentence.")); + }); + + it("fix splits all three sentences in one pass", () => { + const lines = ["One. Two. Three."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + const insert = errors[0].fixInfo.insertText; + assert.ok(insert.includes("Two.") && insert.includes("Three."), "insertText should contain both second and third sentence"); + assert.strictEqual((insert.match(/\n/g) || []).length, 2, "one newline before Two, one before Three"); + }); + + it("does not split on e.g. abbreviation", () => { + const lines = ["Use examples e.g. and more text here."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("does not split on decimal numbers", () => { + const lines = ["The value is 3.14 and that is fine."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("does not split when period has no space after it (e.g. filenames)", () => { + const lines = [ + "See file.name and config.json for details.", + "Edit utils.js or index.ts.", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("splits when period is followed by space even with filename elsewhere", () => { + const lines = ["Open file.txt. Then save and close."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Then save")); + }); + + it("does not split on period inside quoted filename, splits after quote when space follows", () => { + const lines = ['This line has a file named "filename.txt". Some other text.']; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Some other text.")); + assert.ok(!errors[0].fixInfo.insertText.includes("filename.txt"), "should not split inside quoted filename"); + }); + + it("does not split on periods inside double-quoted numbering examples", () => { + const lines = [ + ' Duplicate pairs include "1. Overview" with "2. Overview", and "1.1 Scope" with "2.1 Scope".', + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0, "periods inside double-quoted labels (e.g. 1. Overview) are not sentence boundaries"); + }); + + it("splits after quoted sentence when closing quote then space then new sentence", () => { + const lines = ['"Quoted sentence." This is a new sentence."']; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("This is a new sentence")); + }); + + it("does not split inside inline code", () => { + const lines = ["Run `cmd. exe` and then stop."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips fenced code blocks", () => { + const lines = [ + "```", + "First line. Second line.", + "```", + ]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips ATX headings", () => { + const lines = ["## Heading with. Multiple parts."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips link reference definitions", () => { + const lines = ["[id]: https://example.com. More text."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips blank lines", () => { + const lines = ["", " ", "One sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("skips rule when file path matches excludePathPatterns", () => { + const lines = ["First. Second."]; + const config = { "one-sentence-per-line": { excludePathPatterns: ["**/README.md"] } }; + const errorsMatch = runRule(rule, lines, config, "project/README.md"); + assert.strictEqual(errorsMatch.length, 0); + const errorsNoMatch = runRule(rule, lines, config, "project/doc.md"); + assert.strictEqual(errorsNoMatch.length, 1); + }); + + it("does not skip when excludePathPatterns is empty array", () => { + const lines = ["First. Second."]; + const config = { "one-sentence-per-line": { excludePathPatterns: [] } }; + const errors = runRule(rule, lines, config, "any.md"); + assert.strictEqual(errors.length, 1); + }); + + it("fixInfo has editColumn, deleteCount, insertText", () => { + const lines = ["Alpha. Beta."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + const fix = errors[0].fixInfo; + assert.ok(fix.editColumn >= 1); + assert.ok(fix.deleteCount >= 1); + assert.ok(fix.insertText.includes("Beta.")); + }); + + it("reports error for question and exclamation", () => { + const lines = ["Really? Yes!"]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + }); + + it("skips front matter then reports error after", () => { + const lines = ["---", "title: Doc", "---", "", "First. Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].lineNumber, 5); + }); + + it("does not split period inside parentheses (link context)", () => { + const lines = ["See (e.g. example). More text."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("More text.")); + }); + + it("splits after period when optional quote follows", () => { + const lines = ["First.\" Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Second")); + }); + + it("uses continuationIndent for indented paragraph continuation", () => { + const lines = [" First. Second."]; + const config = { "one-sentence-per-line": { continuationIndent: 2 } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.startsWith("\n "), "continuation should be 2 spaces when paragraph is indented"); + }); + + it("uses no indent for unindented paragraph continuation", () => { + const lines = ["First. Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].fixInfo.insertText, "\nSecond.", "continuation should have no leading space when base line is not indented"); + }); + + it("uses strictAbbreviations when provided as array", () => { + const lines = ["No abbrev. Here."]; + const config = { "one-sentence-per-line": { strictAbbreviations: ["No"] } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + + it("skips line with only list marker and no content", () => { + const lines = ["- ", "1. "]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("getFirstSentenceBoundary uses default abbreviations when opts omitted", () => { + assert.strictEqual(rule.getFirstSentenceBoundary("First. Second."), 6); + }); + + it("getFirstSentenceBoundary uses default abbreviations when opts.abbreviations omitted", () => { + assert.strictEqual(rule.getFirstSentenceBoundary("First. Second.", {}), 6); + }); + + it("getFirstSentenceBoundary returns null when sentence end at start", () => { + assert.strictEqual(rule.getFirstSentenceBoundary(". A."), null); + }); + + it("runs with config undefined (uses default rule config)", () => { + const lines = ["One. Two."]; + const errors = runRule(rule, lines, undefined); + assert.strictEqual(errors.length, 1); + }); + + describe("edge cases (sentence boundary)", () => { + it("does not split on Dr. abbreviation (Dr in default list)", () => { + const lines = ["See Dr. Smith for details."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("splits when ellipsis is not sentence end and real sentence end follows", () => { + const lines = ["First... Then the next sentence."]; + const errors = runRule(rule, lines); + assert.ok(errors.length >= 1, "ellipsis then space then capital should be boundary"); + }); + + it("handles multiple spaces between sentences", () => { + const lines = ["First. Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo); + }); + + it("strictAbbreviations empty array treats every period+space as boundary", () => { + const lines = ["No abbrev. Here."]; + const config = { "one-sentence-per-line": { strictAbbreviations: [] } }; + const errors = runRule(rule, lines, config); + assert.strictEqual(errors.length, 1); + }); + + it("getFirstSentenceBoundary returns null for empty string", () => { + assert.strictEqual(rule.getFirstSentenceBoundary(""), null); + }); + + it("skips line that is only whitespace after trim", () => { + const lines = [" ", "One sentence."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 0); + }); + + it("version number 1. not treated as sentence end", () => { + const lines = ["Use version 1. It is stable."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("It is stable")); + }); + + it("bullet with multiple spaces after marker", () => { + const lines = ["- First. Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + assert.ok(errors[0].fixInfo.insertText.includes("Second.")); + }); + + it("numbered list with period in number (1. First. Second.)", () => { + const lines = ["1. First. Second."]; + const errors = runRule(rule, lines); + assert.strictEqual(errors.length, 1); + }); + }); +}); diff --git a/test/markdownlint-rules/utils.test.js b/test/markdownlint-rules/utils.test.js new file mode 100644 index 0000000..10ab091 --- /dev/null +++ b/test/markdownlint-rules/utils.test.js @@ -0,0 +1,69 @@ +"use strict"; + +/** + * Unit tests for utils.js helpers used by custom rules (e.g. isRuleSuppressedByComment). + */ + +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { isRuleSuppressedByComment } = require("../../markdownlint-rules/utils.js"); + +describe("utils", () => { + describe("isRuleSuppressedByComment", () => { + it("returns true when previous line is solely the suppress comment", () => { + const lines = ["", "## Empty", "## Next"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 2, "no-empty-heading"), true); + }); + + it("returns true when current line ends with the suppress comment", () => { + const lines = ["## Empty section ", "## Next"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 1, "no-empty-heading"), true); + }); + + it("returns true with optional whitespace in comment (previous line)", () => { + const lines = [" ", "Café"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 2, "ascii-only"), true); + }); + + it("returns false when wrong rule name in comment (previous line)", () => { + const lines = ["", "## Single"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 2, "heading-min-words"), false); + }); + + it("returns false when no comment present", () => { + const lines = ["## Empty", "## Next"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 1, "no-empty-heading"), false); + }); + + it("returns false when lineNumber is 1 and no comment on line 1", () => { + const lines = ["## First heading"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 1, "no-empty-heading"), false); + }); + + it("returns true when lineNumber is 1 and line 1 is only the comment", () => { + const lines = ["", "second line"]; + assert.strictEqual(isRuleSuppressedByComment(lines, 1, "document-length"), true); + }); + + it("returns false for invalid inputs (null lines, out of range)", () => { + assert.strictEqual(isRuleSuppressedByComment(null, 1, "x"), false); + assert.strictEqual(isRuleSuppressedByComment([], 1, "x"), false); + assert.strictEqual(isRuleSuppressedByComment(["a"], 0, "x"), false); + assert.strictEqual(isRuleSuppressedByComment(["a"], 2, "x"), false); + assert.strictEqual(isRuleSuppressedByComment(["a"], 1, ""), false); + assert.strictEqual(isRuleSuppressedByComment(["a"], 1, null), false); + }); + + it("returns false when current line is undefined (e.g. sparse array)", () => { + const lines = Array(3); + lines[0] = "first"; + lines[2] = "third"; + assert.strictEqual(isRuleSuppressedByComment(lines, 2, "x"), false); + }); + + it("returns true when line ends with markdownlint-cleared comment form (dots)", () => { + const lines = ["Use arrow → here. "]; + assert.strictEqual(isRuleSuppressedByComment(lines, 1, "ascii-only"), true); + }); + }); +});