Skip to content

Bug: no-reversed-media-syntax hangs on parenthesis-dense prose (catastrophic backtracking) #690

Description

@virajp

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

  • I am willing to submit a pull request for this issue.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Status
Implementing

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions