From 8d97505ac59067d6c05a5feaeef0b3b6431afa5b Mon Sep 17 00:00:00 2001
From: Minsu <52266597+Gaic4o@users.noreply.github.com>
Date: Sun, 16 Aug 2026 16:38:06 +0900
Subject: [PATCH 1/3] feat: add no-heading-like-paragraph rule
---
README.md | 1 +
docs/rules/no-heading-like-paragraph.md | 73 +++
src/rules/no-heading-like-paragraph.js | 117 ++++
tests/rules/no-heading-like-paragraph.test.js | 522 ++++++++++++++++++
4 files changed, 713 insertions(+)
create mode 100644 docs/rules/no-heading-like-paragraph.md
create mode 100644 src/rules/no-heading-like-paragraph.js
create mode 100644 tests/rules/no-heading-like-paragraph.test.js
diff --git a/README.md b/README.md
index f527a680..97d1d238 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,7 @@ export default defineConfig([
| [`no-empty-definitions`](./docs/rules/no-empty-definitions.md) | Disallow empty definitions | yes |
| [`no-empty-images`](./docs/rules/no-empty-images.md) | Disallow empty images | yes |
| [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links | yes |
+| [`no-heading-like-paragraph`](./docs/rules/no-heading-like-paragraph.md) | Disallow paragraphs that look like ATX headings | no |
| [`no-html`](./docs/rules/no-html.md) | Disallow HTML tags | no |
| [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references | yes |
| [`no-missing-atx-heading-space`](./docs/rules/no-missing-atx-heading-space.md) | Disallow headings without a space after the hash characters | yes |
diff --git a/docs/rules/no-heading-like-paragraph.md b/docs/rules/no-heading-like-paragraph.md
new file mode 100644
index 00000000..c0b77cef
--- /dev/null
+++ b/docs/rules/no-heading-like-paragraph.md
@@ -0,0 +1,73 @@
+# no-heading-like-paragraph
+
+Disallow paragraphs that look like ATX headings.
+
+## Background
+
+In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space or a tab, so `###### Installation` is a level 6 heading. A seventh hash character exceeds the maximum heading depth, and `####### Installation` doesn't create a heading at all. Markdown renders it as a paragraph whose text starts with seven literal hash characters.
+
+This is almost always a typo, and it's easy to miss in review because the source still reads like a heading.
+
+## Rule Details
+
+This rule flags a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph.
+
+This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, and `\####### Installation` and `####### Installation` escape their leading hash character on purpose.
+
+This rule provides suggestions rather than an automatic fix, because the number of hash characters alone doesn't reveal which correction the author intended:
+
+* Replace the leading hash characters with `######`, which makes the paragraph a level 6 heading. `####### Installation` becomes `###### Installation`.
+* Escape the leading hash character, which leaves the rendered output unchanged. `####### Installation` becomes `\####### Installation`.
+
+Examples of **incorrect** code for this rule:
+
+```markdown
+
+
+####### Installation
+
+######## Configuration
+
+> ####### Usage
+
+- ####### Options
+```
+
+Examples of **correct** code for this rule:
+
+```markdown
+
+
+###### Installation
+
+> ###### Usage
+
+- ###### Options
+
+#######Configuration
+
+\####### Not a heading
+
+Seven ####### characters in the middle of a paragraph.
+```
+
+This rule only checks the beginning of a paragraph, so it ignores hash characters on a continuation line:
+
+```markdown
+Install the package first.
+####### Installation
+```
+
+Because `####### Installation` can't start a heading, Markdown folds it into the preceding paragraph as a lazy continuation line. The same text with six or fewer hash characters would interrupt the paragraph and become a real heading.
+
+## When Not to Use It
+
+If you intentionally write paragraphs that begin with seven or more hash characters, you can safely disable this rule.
+
+## Prior Art
+
+* [remark-lint-no-heading-like-paragraph](https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-no-heading-like-paragraph)
+
+## Further Reading
+
+* [CommonMark Spec: ATX Headings](https://spec.commonmark.org/0.31.2/#atx-headings)
diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js
new file mode 100644
index 00000000..f80197be
--- /dev/null
+++ b/src/rules/no-heading-like-paragraph.js
@@ -0,0 +1,117 @@
+/**
+ * @fileoverview Rule to disallow paragraphs that look like ATX headings in Markdown.
+ * @author Gaic4o
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/**
+ * @import { MarkdownRuleDefinition } from "../types.js";
+ * @typedef {"headingLikeParagraph" | "useMaxDepthHashes" | "escapeLeadingHash"} NoHeadingLikeParagraphMessageIds
+ * @typedef {[]} NoHeadingLikeParagraphOptions
+ * @typedef {MarkdownRuleDefinition<{ RuleOptions: NoHeadingLikeParagraphOptions, MessageIds: NoHeadingLikeParagraphMessageIds }>} NoHeadingLikeParagraphRuleDefinition
+ */
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+/**
+ * Matches seven or more hash characters at the start of a paragraph, followed by a
+ * space, a tab, a line ending, or the end of the paragraph. This mirrors the way
+ * CommonMark delimits the opening sequence of an ATX heading, so a no-break space
+ * doesn't count as a delimiter.
+ */
+const headingLikeParagraphPattern = /^#{7,}(?=[ \t\r\n]|$)/u;
+
+/** The longest opening sequence an ATX heading allows. */
+const maxDepthHashes = "######";
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({
+ meta: {
+ type: "problem",
+
+ docs: {
+ recommended: false,
+ description: "Disallow paragraphs that look like ATX headings",
+ url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-heading-like-paragraph.md",
+ },
+
+ hasSuggestions: true,
+
+ messages: {
+ headingLikeParagraph:
+ "Unexpected paragraph starting with {{count}} hash characters. ATX headings support at most 6.",
+ useMaxDepthHashes: 'Replace "{{hashes}}" with "{{maxDepthHashes}}".',
+ escapeLeadingHash: "Escape the leading hash character.",
+ },
+ },
+
+ create(context) {
+ const { sourceCode } = context;
+
+ return {
+ paragraph(node) {
+ /*
+ * Read the raw source text instead of the `value` of the first `text`
+ * child, because `value` already resolves character escapes and character
+ * references. Both `\####### Foo` and `####### Foo` render as a
+ * paragraph whose text starts with seven hash characters, but in each case
+ * the author escaped the leading hash on purpose.
+ */
+ const match = headingLikeParagraphPattern.exec(
+ sourceCode.getText(node),
+ );
+
+ if (match === null) {
+ return;
+ }
+
+ const [hashes] = match;
+ const startOffset = node.position.start.offset;
+ const endOffset = startOffset + hashes.length;
+
+ context.report({
+ loc: {
+ start: sourceCode.getLocFromIndex(startOffset),
+ end: sourceCode.getLocFromIndex(endOffset),
+ },
+ messageId: "headingLikeParagraph",
+ data: { count: hashes.length },
+
+ /*
+ * There's no autofix, because the number of hash characters alone
+ * doesn't reveal which of the two corrections the author intended.
+ */
+ suggest: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: { hashes, maxDepthHashes },
+ fix(fixer) {
+ return fixer.replaceTextRange(
+ [startOffset, endOffset],
+ maxDepthHashes,
+ );
+ },
+ },
+ {
+ messageId: "escapeLeadingHash",
+ fix(fixer) {
+ return fixer.insertTextBeforeRange(
+ [startOffset, startOffset + 1],
+ "\\",
+ );
+ },
+ },
+ ],
+ });
+ },
+ };
+ },
+});
diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js
new file mode 100644
index 00000000..9e8bc884
--- /dev/null
+++ b/tests/rules/no-heading-like-paragraph.test.js
@@ -0,0 +1,522 @@
+/**
+ * @fileoverview Tests for no-heading-like-paragraph rule.
+ * @author Gaic4o
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-heading-like-paragraph.js";
+import markdown from "../../src/index.js";
+import { RuleTester } from "eslint";
+import dedent from "dedent";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+ plugins: {
+ markdown,
+ },
+ language: "markdown/commonmark",
+});
+
+ruleTester.run("no-heading-like-paragraph", rule, {
+ valid: [
+ // Basic
+ "",
+ " ",
+ "Installation",
+ "###### Installation",
+ "# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six",
+
+ // Setext heading
+ "####### Installation\n===",
+
+ // Not an opening sequence
+ "#######Installation",
+ "Installation ####### Configuration",
+ "###### ####### Installation",
+ "#######\u00A0Installation", // a no-break space doesn't delimit an opening sequence
+ "#######*Installation*",
+
+ // Escapes and character references
+ "\\####### Installation",
+ "####### Installation",
+
+ // Code
+ "```md\n####### Installation\n```",
+ " ####### Installation",
+ "`####### Installation`",
+
+ // HTML
+ "
\n####### Installation\n
",
+
+ // Paragraph continuation line
+ "Installation\n####### Configuration",
+ ],
+
+ invalid: [
+ // Basic
+ {
+ code: "####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "######## Configuration",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "8" },
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 9,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "########",
+ maxDepthHashes: "######",
+ },
+ output: "###### Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\######## Configuration",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "#######\tInstallation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "######\tInstallation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\#######\tInstallation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "#######",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "######",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\#######",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "####### ",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### ",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### ",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "#######\nInstallation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "######\nInstallation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\#######\nInstallation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "#######\r\nInstallation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "######\r\nInstallation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\#######\r\nInstallation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "####### Installation #######",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### Installation #######",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### Installation #######",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "####### **Installation**",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### **Installation**",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### **Installation**",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "####### Installation\nRun the following command.",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### Installation\nRun the following command.",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### Installation\nRun the following command.",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: dedent`####### Installation
+
+ ######## Configuration`,
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### Installation\n\n######## Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### Installation\n\n######## Configuration",
+ },
+ ],
+ },
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "8" },
+ line: 3,
+ column: 1,
+ endLine: 3,
+ endColumn: 9,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "####### Installation\n\n###### Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "####### Installation\n\n\\######## Configuration",
+ },
+ ],
+ },
+ ],
+ },
+
+ // Indent
+ {
+ code: " ####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 4,
+ endLine: 1,
+ endColumn: 11,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: " ###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: " \\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+
+ // Blockquote
+ {
+ code: "> ####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 3,
+ endLine: 1,
+ endColumn: 10,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "> ###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "> \\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "> > ####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 5,
+ endLine: 1,
+ endColumn: 12,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "> > ###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "> > \\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+
+ // List item
+ {
+ code: "- ####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 3,
+ endLine: 1,
+ endColumn: 10,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "- ###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "- \\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "- Installation\n\n ####### Configuration",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 3,
+ column: 3,
+ endLine: 3,
+ endColumn: 10,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "- Installation\n\n ###### Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "- Installation\n\n \\####### Configuration",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+//------------------------------------------------------------------------------
+// GFM Tests
+//------------------------------------------------------------------------------
+
+const gfmRuleTester = new RuleTester({
+ plugins: {
+ markdown,
+ },
+ language: "markdown/gfm",
+});
+
+gfmRuleTester.run("no-heading-like-paragraph", rule, {
+ valid: [
+ "###### Installation",
+ "#######Installation",
+ "| ####### Installation |\n| --- |",
+ ],
+
+ invalid: [
+ {
+ code: "####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 1,
+ column: 1,
+ endLine: 1,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "\\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: dedent`[^note]: ####### Installation
+
+ Text[^note]`,
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ line: 1,
+ column: 10,
+ endLine: 1,
+ endColumn: 17,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ output: "[^note]: ###### Installation\n\nText[^note]",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "[^note]: \\####### Installation\n\nText[^note]",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
From bfd826fe2459983ad2974802edeba92019ae58a1 Mon Sep 17 00:00:00 2001
From: Minsu <52266597+Gaic4o@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:29:51 +0900
Subject: [PATCH 2/3] fix: apply review feedback
---
src/rules/no-heading-like-paragraph.js | 9 +-
tests/rules/no-heading-like-paragraph.test.js | 120 +++++++++++++++---
2 files changed, 101 insertions(+), 28 deletions(-)
diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js
index f80197be..87122e94 100644
--- a/src/rules/no-heading-like-paragraph.js
+++ b/src/rules/no-heading-like-paragraph.js
@@ -38,7 +38,6 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({
type: "problem",
docs: {
- recommended: false,
description: "Disallow paragraphs that look like ATX headings",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-heading-like-paragraph.md",
},
@@ -48,7 +47,8 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({
messages: {
headingLikeParagraph:
"Unexpected paragraph starting with {{count}} hash characters. ATX headings support at most 6.",
- useMaxDepthHashes: 'Replace "{{hashes}}" with "{{maxDepthHashes}}".',
+ useMaxDepthHashes:
+ 'Replace "{{hashes}}" with "{{maxDepthHashes}}".',
escapeLeadingHash: "Escape the leading hash character.",
},
},
@@ -84,11 +84,6 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({
},
messageId: "headingLikeParagraph",
data: { count: hashes.length },
-
- /*
- * There's no autofix, because the number of hash characters alone
- * doesn't reveal which of the two corrections the author intended.
- */
suggest: [
{
messageId: "useMaxDepthHashes",
diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js
index 9e8bc884..110d3615 100644
--- a/tests/rules/no-heading-like-paragraph.test.js
+++ b/tests/rules/no-heading-like-paragraph.test.js
@@ -56,6 +56,20 @@ ruleTester.run("no-heading-like-paragraph", rule, {
// Paragraph continuation line
"Installation\n####### Configuration",
+
+ // GFM
+ {
+ code: "###### Installation",
+ language: "markdown/gfm",
+ },
+ {
+ code: "#######Installation",
+ language: "markdown/gfm",
+ },
+ {
+ code: "| ####### Installation |\n| --- |",
+ language: "markdown/gfm",
+ },
],
invalid: [
@@ -119,6 +133,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -126,6 +141,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "######\tInstallation",
},
{
@@ -141,6 +160,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -148,6 +168,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "######",
},
{
@@ -163,6 +187,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -170,6 +195,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### ",
},
{
@@ -185,6 +214,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -192,6 +222,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "######\nInstallation",
},
{
@@ -207,6 +241,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -214,6 +249,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "######\r\nInstallation",
},
{
@@ -229,6 +268,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -236,6 +276,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### Installation #######",
},
{
@@ -251,6 +295,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -258,6 +303,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### **Installation**",
},
{
@@ -273,6 +322,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 1,
endLine: 1,
@@ -280,6 +330,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### Installation\nRun the following command.",
},
{
@@ -305,6 +359,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### Installation\n\n######## Configuration",
},
{
@@ -323,6 +381,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "########",
+ maxDepthHashes: "######",
+ },
output: "####### Installation\n\n###### Configuration",
},
{
@@ -340,6 +402,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 4,
endLine: 1,
@@ -347,6 +410,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: " ###### Installation",
},
{
@@ -364,6 +431,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 3,
endLine: 1,
@@ -371,6 +439,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "> ###### Installation",
},
{
@@ -386,6 +458,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 5,
endLine: 1,
@@ -393,6 +466,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "> > ###### Installation",
},
{
@@ -410,6 +487,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 3,
endLine: 1,
@@ -417,6 +495,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "- ###### Installation",
},
{
@@ -432,6 +514,7 @@ ruleTester.run("no-heading-like-paragraph", rule, {
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 3,
column: 3,
endLine: 3,
@@ -439,6 +522,10 @@ ruleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "- Installation\n\n ###### Configuration",
},
{
@@ -449,30 +536,11 @@ ruleTester.run("no-heading-like-paragraph", rule, {
},
],
},
- ],
-});
-
-//------------------------------------------------------------------------------
-// GFM Tests
-//------------------------------------------------------------------------------
-
-const gfmRuleTester = new RuleTester({
- plugins: {
- markdown,
- },
- language: "markdown/gfm",
-});
-
-gfmRuleTester.run("no-heading-like-paragraph", rule, {
- valid: [
- "###### Installation",
- "#######Installation",
- "| ####### Installation |\n| --- |",
- ],
- invalid: [
+ // GFM
{
code: "####### Installation",
+ language: "markdown/gfm",
errors: [
{
messageId: "headingLikeParagraph",
@@ -484,6 +552,10 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "###### Installation",
},
{
@@ -498,9 +570,11 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, {
code: dedent`[^note]: ####### Installation
Text[^note]`,
+ language: "markdown/gfm",
errors: [
{
messageId: "headingLikeParagraph",
+ data: { count: "7" },
line: 1,
column: 10,
endLine: 1,
@@ -508,6 +582,10 @@ gfmRuleTester.run("no-heading-like-paragraph", rule, {
suggestions: [
{
messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
output: "[^note]: ###### Installation\n\nText[^note]",
},
{
From d5381ee3d6e739469711e6eb1fef25c87b7ff488 Mon Sep 17 00:00:00 2001
From: Minsu <52266597+Gaic4o@users.noreply.github.com>
Date: Tue, 1 Sep 2026 15:40:22 +0900
Subject: [PATCH 3/3] fix: handle multiline heading-like paragraphs
---
docs/rules/no-heading-like-paragraph.md | 27 +-
src/rules/no-heading-like-paragraph.js | 96 ++++---
tests/rules/no-heading-like-paragraph.test.js | 256 +++++++++++++++++-
3 files changed, 327 insertions(+), 52 deletions(-)
diff --git a/docs/rules/no-heading-like-paragraph.md b/docs/rules/no-heading-like-paragraph.md
index c0b77cef..70db1636 100644
--- a/docs/rules/no-heading-like-paragraph.md
+++ b/docs/rules/no-heading-like-paragraph.md
@@ -4,15 +4,15 @@ Disallow paragraphs that look like ATX headings.
## Background
-In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space or a tab, so `###### Installation` is a level 6 heading. A seventh hash character exceeds the maximum heading depth, and `####### Installation` doesn't create a heading at all. Markdown renders it as a paragraph whose text starts with seven literal hash characters.
+In Markdown, an ATX heading opens with one to six hash (`#`) characters followed by a space, a tab, or a line ending, so `###### Installation` is a level 6 heading. Seven or more hash characters aren't heading syntax at all, so Markdown reads `####### Installation` as paragraph text that begins with seven literal hash characters.
This is almost always a typo, and it's easy to miss in review because the source still reads like a heading.
## Rule Details
-This rule flags a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph.
+This rule flags a line of a paragraph that begins with seven or more hash characters followed by a space, a tab, a line ending, or the end of the paragraph. It checks continuation lines as well as the first line, because six or fewer hash characters in the same position would open a real heading. Block quote markers and up to three spaces of indentation may precede the hash characters, the same positions where an ATX heading is allowed to start.
-This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, and `\####### Installation` and `####### Installation` escape their leading hash character on purpose.
+This rule ignores anything that can't open an ATX heading. `#######Installation` has no whitespace to delimit the hash characters, `\####### Installation` and `####### Installation` escape their leading hash character on purpose, and four or more spaces of indentation are too many for a heading.
This rule provides suggestions rather than an automatic fix, because the number of hash characters alone doesn't reveal which correction the author intended:
@@ -31,6 +31,13 @@ Examples of **incorrect** code for this rule:
> ####### Usage
- ####### Options
+
+Install the package first.
+####### Installation
+
+> foo
+> ####### hi
+> bar
```
Examples of **correct** code for this rule:
@@ -49,16 +56,18 @@ Examples of **correct** code for this rule:
\####### Not a heading
Seven ####### characters in the middle of a paragraph.
-```
-
-This rule only checks the beginning of a paragraph, so it ignores hash characters on a continuation line:
-```markdown
Install the package first.
-####### Installation
+###### Installation
+
+> foo
+> ###### hi
+> bar
```
-Because `####### Installation` can't start a heading, Markdown folds it into the preceding paragraph as a lazy continuation line. The same text with six or fewer hash characters would interrupt the paragraph and become a real heading.
+## Options
+
+This rule has no options.
## When Not to Use It
diff --git a/src/rules/no-heading-like-paragraph.js b/src/rules/no-heading-like-paragraph.js
index 87122e94..5166d4c8 100644
--- a/src/rules/no-heading-like-paragraph.js
+++ b/src/rules/no-heading-like-paragraph.js
@@ -19,12 +19,21 @@
//-----------------------------------------------------------------------------
/**
- * Matches seven or more hash characters at the start of a paragraph, followed by a
- * space, a tab, a line ending, or the end of the paragraph. This mirrors the way
- * CommonMark delimits the opening sequence of an ATX heading, so a no-break space
- * doesn't count as a delimiter.
+ * Matches seven or more hash characters at the start of a line within a paragraph,
+ * followed by a space, a tab, a line ending, or the end of the paragraph. This mirrors
+ * the way CommonMark delimits the opening sequence of an ATX heading, so a no-break
+ * space doesn't count as a delimiter.
+ *
+ * This pattern avoids the `m` flag, which would also treat U+2028 and U+2029 as line
+ * boundaries even though Markdown doesn't. `(?:^|(?<=[\r\n]))` starts a new line only
+ * after an actual carriage return or line feed.
+ *
+ * Block quote markers and up to three spaces of indentation may precede the hash
+ * characters, because a heading with six or fewer hash characters would still open in
+ * that position.
*/
-const headingLikeParagraphPattern = /^#{7,}(?=[ \t\r\n]|$)/u;
+const headingLikeParagraphPattern =
+ /(?:^|(?<=[\r\n]))(?: {0,3}>[ \t]?)* {0,3}(?#{7,})(?=[ \t\r\n]|$)/gu;
/** The longest opening sequence an ATX heading allows. */
const maxDepthHashes = "######";
@@ -65,47 +74,52 @@ export default /** @satisfies {NoHeadingLikeParagraphRuleDefinition} */ ({
* paragraph whose text starts with seven hash characters, but in each case
* the author escaped the leading hash on purpose.
*/
- const match = headingLikeParagraphPattern.exec(
- sourceCode.getText(node),
- );
+ const text = sourceCode.getText(node);
- if (match === null) {
- return;
- }
+ /** @type {RegExpExecArray | null} */
+ let match;
- const [hashes] = match;
- const startOffset = node.position.start.offset;
- const endOffset = startOffset + hashes.length;
+ while (
+ (match = headingLikeParagraphPattern.exec(text)) !== null
+ ) {
+ const { hashes } = match.groups;
+ const startOffset =
+ node.position.start.offset +
+ match.index +
+ match[0].length -
+ hashes.length;
+ const endOffset = startOffset + hashes.length;
- context.report({
- loc: {
- start: sourceCode.getLocFromIndex(startOffset),
- end: sourceCode.getLocFromIndex(endOffset),
- },
- messageId: "headingLikeParagraph",
- data: { count: hashes.length },
- suggest: [
- {
- messageId: "useMaxDepthHashes",
- data: { hashes, maxDepthHashes },
- fix(fixer) {
- return fixer.replaceTextRange(
- [startOffset, endOffset],
- maxDepthHashes,
- );
- },
+ context.report({
+ loc: {
+ start: sourceCode.getLocFromIndex(startOffset),
+ end: sourceCode.getLocFromIndex(endOffset),
},
- {
- messageId: "escapeLeadingHash",
- fix(fixer) {
- return fixer.insertTextBeforeRange(
- [startOffset, startOffset + 1],
- "\\",
- );
+ messageId: "headingLikeParagraph",
+ data: { count: hashes.length },
+ suggest: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: { hashes, maxDepthHashes },
+ fix(fixer) {
+ return fixer.replaceTextRange(
+ [startOffset, endOffset],
+ maxDepthHashes,
+ );
+ },
},
- },
- ],
- });
+ {
+ messageId: "escapeLeadingHash",
+ fix(fixer) {
+ return fixer.insertTextBeforeRange(
+ [startOffset, startOffset + 1],
+ "\\",
+ );
+ },
+ },
+ ],
+ });
+ }
},
};
},
diff --git a/tests/rules/no-heading-like-paragraph.test.js b/tests/rules/no-heading-like-paragraph.test.js
index 110d3615..1ecd61cc 100644
--- a/tests/rules/no-heading-like-paragraph.test.js
+++ b/tests/rules/no-heading-like-paragraph.test.js
@@ -49,13 +49,24 @@ ruleTester.run("no-heading-like-paragraph", rule, {
// Code
"```md\n####### Installation\n```",
" ####### Installation",
+
+ // InlineCode
"`####### Installation`",
// HTML
"\n####### Installation\n
",
- // Paragraph continuation line
- "Installation\n####### Configuration",
+ // Continuation line that can't open a heading
+ "foo\n ####### bar", // four spaces of indentation are too many for a heading
+ "> foo\n> ####### bar", // the block quote marker eats one space, leaving four
+
+ // Line separator (U+2028) and paragraph separator (U+2029) aren't Markdown line
+ // endings, so the hash characters stay in the middle of a line
+ "Installation\u2028####### Configuration",
+ "Installation\u2029####### Configuration",
+
+ // Block quote
+ "> foo\n> ###### hi\n> bar",
// GFM
{
@@ -425,6 +436,165 @@ ruleTester.run("no-heading-like-paragraph", rule, {
],
},
+ // Continuation line
+ {
+ code: "Some paragraph text.\n####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph text.\n###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph text.\n\\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "Some paragraph\r\n####### Heading",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph\r\n###### Heading",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph\r\n\\####### Heading",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "Some paragraph\r####### Heading",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph\r###### Heading",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph\r\\####### Heading",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "Some paragraph text.\n ####### Installation",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 4,
+ endLine: 2,
+ endColumn: 11,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph text.\n ###### Installation",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph text.\n \\####### Installation",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "Some paragraph text.\n####### Installation\n####### Configuration",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph text.\n###### Installation\n####### Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph text.\n\\####### Installation\n####### Configuration",
+ },
+ ],
+ },
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 3,
+ column: 1,
+ endLine: 3,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "Some paragraph text.\n####### Installation\n###### Configuration",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "Some paragraph text.\n####### Installation\n\\####### Configuration",
+ },
+ ],
+ },
+ ],
+ },
+
// Blockquote
{
code: "> ####### Installation",
@@ -481,6 +651,88 @@ ruleTester.run("no-heading-like-paragraph", rule, {
],
},
+ {
+ code: "> foo\n> ####### hi\n> bar",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 3,
+ endLine: 2,
+ endColumn: 10,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "> foo\n> ###### hi\n> bar",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "> foo\n> \\####### hi\n> bar",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "> > foo\n> > ####### hi",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 5,
+ endLine: 2,
+ endColumn: 12,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "> > foo\n> > ###### hi",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "> > foo\n> > \\####### hi",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: "> foo\n####### hi",
+ errors: [
+ {
+ messageId: "headingLikeParagraph",
+ data: { count: "7" },
+ line: 2,
+ column: 1,
+ endLine: 2,
+ endColumn: 8,
+ suggestions: [
+ {
+ messageId: "useMaxDepthHashes",
+ data: {
+ hashes: "#######",
+ maxDepthHashes: "######",
+ },
+ output: "> foo\n###### hi",
+ },
+ {
+ messageId: "escapeLeadingHash",
+ output: "> foo\n\\####### hi",
+ },
+ ],
+ },
+ ],
+ },
+
// List item
{
code: "- ####### Installation",