ResearchDocumentation

Docs markdown linting: rumdl adoption rationale

Status: Reference — captures why rumdl was selected as the mechanical markdown linter for the docs corpus, and the alternatives rejected. See Docs markdown linting (rumdl) for the current implementation state.

Problem

The docs site under docs/content/docs/ has automated gates for structural breakage (the Fumadocs build), broken links (lychee), code-span references to real repo files (cargo xtask docs repo-links), and roadmap sidebar completeness (cargo xtask roadmap audit). The Rust implementation lives in crates/jackin-xtask/src/docs.rs. Nothing gates mechanical markdown style — heading hierarchy, code-fence languages, list-marker consistency, line-length policy, emphasis-marker mixing. Each PR's MDX looks fine in isolation; the corpus drifts across contributors and sessions without a deterministic check.

Why it matters

A markdown linter does not catch deep bugs; it is worth adding for the same reason cargo clippy -D warnings is on the Rust side — one enforced source of truth for style frees review attention for substance. Concretely: a code fence without a language tag loses syntax highlighting, a skipped heading level (## to ####) breaks Fumadocs' right-rail table of contents, and mixed -/* bullets make diffs noisier than they need to be. None of these block a single PR; together they erode trust in the docs surface over the corpus's lifetime. A prose rule in docs/AGENTS.md is a polite request; a CI rule is a constraint — the project already learned this with cargo xtask docs repo-links, written because "use <RepoFile>" was a soft policy contributors and agents inconsistently followed.

The case is sharper because most docs edits are agent-produced. An agent session sees CLAUDE.md/AGENTS.md and a handful of nearby files; it cannot internalize every existing style decision and instead produces a plausible local extrapolation, which diverges across agent vendors (Codex favors compact lists, Claude favors spacious ones) and across sessions of the same agent. Agents also respond to deterministic, rule-coded feedback ("MD040: code fence missing language at line 23") far more reliably than to fuzzy review comments ("this looks slightly off"). A linter converts the latter into the former.

Decision: rumdl

rumdl — a Rust port of markdownlint with native MDX support and an official GitHub Action — was selected over the alternatives below:

  • Implements all 53 MD001-MD059 markdownlint rules plus 18 rumdl-specific rules (relative-link existence, forbidden-term policy, ToC validation, footnote rules); functionally on par with markdownlint-cli2.
  • Native MDX support: treats capitalized JSX tags as components rather than malformed HTML, recognizes top-of-file import statements, and auto-relaxes MD013/MD033/emphasis-marker rules inside JSX expressions. General-purpose markdown linters tend to reject MDX outright or false-positive on the self-closing component tags this codebase relies on (<RepoFile />, <Aside />).
  • Single static Rust binary — no Node runtime needed for the lint step, unlike markdownlint-cli2, and aligns with the project's Rust-first tooling posture.
  • Pre-built rvben/rumdl@v0 GitHub Action with version/path/config/report-type inputs; annotations mode surfaces violations directly in the PR "Files changed" tab.
  • Configuration surface is small: a single .rumdl.toml for rule enable/disable, per-file-ignore globs, inline <!-- rumdl-disable --> directives, and extends for inheritance.

Alternatives considered and rejected

  • markdownlint-cli2 (Node). Mature and well-known, but adds a Node dependency for the lint step, weaker out-of-the-box MDX support, slower. No functional advantage at this corpus's scale.
  • remark-lint. AST-based and elegant, but pulls in unified + remark-parse + remark-mdx + a separate package per rule. More dependencies for similar coverage.
  • mado. Earlier-stage Rust port of markdownlint; smaller rule set than rumdl, less active. Worth reassessing if rumdl stalls.
  • No linter, rely on review. The status quo; the cost is style drift accumulating silently across an indefinitely-growing corpus, described above.

Trade-offs and costs

  • One-time cleanup tax. A dry run against the existing corpus (several dozen MDX files at proposal time) will surface a nontrivial violation count, some auto-fixable via rumdl check --fix, some not. The adoption PR is expected to carry a larger-than-usual diff.
  • Rule churn. rumdl ships frequent releases; pin the Action to a SHA and treat upgrades as routine dependency-bump PRs, same trade-off as pinning a Rust toolchain version for clippy.
  • No custom-rule API. rumdl has no plugin/Lua/wasm hook system. Project-specific rules — like "code spans referencing real repo files must be <RepoFile> links" — still need bespoke tooling; rumdl complements cargo xtask docs repo-links, it does not replace it.
  • Disabled-rule creep. Each rule disabled in .rumdl.toml is a hole in the lint surface; the list needs to stay small, justified inline, and periodically re-audited.
  • MDX parser limits. rumdl's MDX support is good but not perfect on deeply nested JSX expressions; cases that hit this are usually a sign the content should be a React component, not inline MDX.

Open design questions

  1. Initial strictness. Start from the default rule set (strict, then disable as violations surface) or a minimal set (loose, then add rules as drift is observed)? Default-strict is more honest about what mechanical rules buy; minimal-then-grow is less upfront work but tends to stay minimal in practice.
  2. Auto-fix in CI. Should CI ever run rumdl check --fix and commit back, or should --fix stay local-only with CI strictly verifying? Local-only is the safer default — auto-fix in CI risks committing unreviewed changes, especially from agent-authored PRs.
  3. Scope. Lint only docs/content/docs/, or also repo-root markdown (README.md, CHANGELOG.md, AGENTS.md, RULES.md, TESTING.md, PROJECT_STRUCTURE.md, DEPRECATED.md, CONTRIBUTING.md)? Those root files are read by humans and agents from session start, so their style consistency arguably matters at least as much as the docs site's.
  4. Failure mode. Hard CI failure (consistent with cargo fmt --check, repo-link checks, roadmap-sidebar checks, lychee) or PR-annotations-only? Whether the CI job is branch-protected is a repository setting, not something decidable from repo files.

On this page