Environment
Node version: v26.5.0
npm version: 11.17.0
Local ESLint version: v10.7.0
Global ESLint version: -
Operating System: macOS (darwin 25.5.0)
What parser are you using?
Default (@eslint/markdown)
What did you do?
markdown/no-reversed-media-syntax exhibits catastrophic backtracking. On prose containing an unclosed ( followed by a moderate number of parenthesised spans (and no )[ anywhere), lint time grows exponentially and the run never completes.
// eslint.config.mjs
import markdown from "@eslint/markdown";
export default [
{
files: ["**/*.md"],
plugins: { markdown },
language: "markdown/gfm",
rules: { "markdown/no-reversed-media-syntax": "error" },
},
];
Generate the fixture:
node -e "require('fs').writeFileSync('repro.md', 'Text (unclosed ' + 'and (x) '.repeat(30) + 'end\n')"
npx eslint repro.md
What did you expect to happen?
ESLint completes promptly and reports no no-reversed-media-syntax violations (there is no reversed (label)[url] syntax in the input).
What actually happened?
ESLint hangs, pinning a CPU core indefinitely. Measured wall-clock against the repeat count n:
n (repeats of and (x) ) |
eslint runtime |
| 26 |
16 s |
| 28 |
64 s |
| 30 |
> 90 s (killed) |
| 32 |
> 90 s (killed) |
Roughly 4× per two additional spans — exponential, not merely slow.
Analysis
The rule's reversedPattern (src/rules/no-reversed-media-syntax.js):
const reversedPattern =
/(?<=(?<!\\)(?:\\{2})*)\((?<label>(?:\\.|[^()\\]|\([\s\S]*\))*)\)\[(?<url>(?:\\.|[^\]\\\r\n])*)\](?!\()/gu;
The label group is (?: \\. | [^()\\] | \([\s\S]*\) )* — an unbounded [\s\S]* nested inside an outer *, where the third alternative can also span the same region the first two can. When the overall match ultimately fails (no )[ follows), the engine must explore every way of partitioning the remaining text between the outer quantifier and the inner [\s\S]*, which is exponential in the number of (...) spans.
This reproduces identically on 8.0.2 and 8.0.3 — the rule source is byte-identical between the two releases.
Real-world impact
This is not a synthetic-input-only issue. It was hit on an ordinary hand-written CLAUDE.md: a single Markdown list item of documentation prose containing ~260 parentheses (nested parentheticals inside a long bullet). The file is otherwise unremarkable, and every other file in the repository lints in milliseconds. Because the rule is in recommended, any project with sufficiently dense parenthetical prose will hang with no indication of which rule is responsible — --debug stops at With flat config: <file> and emits nothing further.
Possible fix
Make the alternation unambiguous and bound the nested span, e.g. replace \([\s\S]*\) with a non-backtracking form such as \([^()]*\) (or make the group atomic / rewrite so each character has exactly one parse). Happy to open a PR if a preferred direction is indicated.
Participation
Environment
Node version: v26.5.0
npm version: 11.17.0
Local ESLint version: v10.7.0
Global ESLint version: -
Operating System: macOS (darwin 25.5.0)
What parser are you using?
Default (@eslint/markdown)
What did you do?
markdown/no-reversed-media-syntaxexhibits catastrophic backtracking. On prose containing an unclosed(followed by a moderate number of parenthesised spans (and no)[anywhere), lint time grows exponentially and the run never completes.Generate the fixture:
node -e "require('fs').writeFileSync('repro.md', 'Text (unclosed ' + 'and (x) '.repeat(30) + 'end\n')" npx eslint repro.mdWhat did you expect to happen?
ESLint completes promptly and reports no
no-reversed-media-syntaxviolations (there is no reversed(label)[url]syntax in the input).What actually happened?
ESLint hangs, pinning a CPU core indefinitely. Measured wall-clock against the repeat count
n:n(repeats ofand (x))Roughly 4× per two additional spans — exponential, not merely slow.
Analysis
The rule's
reversedPattern(src/rules/no-reversed-media-syntax.js):The
labelgroup is(?: \\. | [^()\\] | \([\s\S]*\) )*— an unbounded[\s\S]*nested inside an outer*, where the third alternative can also span the same region the first two can. When the overall match ultimately fails (no)[follows), the engine must explore every way of partitioning the remaining text between the outer quantifier and the inner[\s\S]*, which is exponential in the number of(...)spans.This reproduces identically on 8.0.2 and 8.0.3 — the rule source is byte-identical between the two releases.
Real-world impact
This is not a synthetic-input-only issue. It was hit on an ordinary hand-written
CLAUDE.md: a single Markdown list item of documentation prose containing ~260 parentheses (nested parentheticals inside a long bullet). The file is otherwise unremarkable, and every other file in the repository lints in milliseconds. Because the rule is inrecommended, any project with sufficiently dense parenthetical prose will hang with no indication of which rule is responsible —--debugstops atWith flat config: <file>and emits nothing further.Possible fix
Make the alternation unambiguous and bound the nested span, e.g. replace
\([\s\S]*\)with a non-backtracking form such as\([^()]*\)(or make the group atomic / rewrite so each character has exactly one parse). Happy to open a PR if a preferred direction is indicated.Participation