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-MD059markdownlint rules plus 18 rumdl-specific rules (relative-link existence, forbidden-term policy, ToC validation, footnote rules); functionally on par withmarkdownlint-cli2. - Native MDX support: treats capitalized JSX tags as components rather than malformed HTML, recognizes top-of-file
importstatements, and auto-relaxesMD013/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@v0GitHub Action withversion/path/config/report-typeinputs;annotationsmode surfaces violations directly in the PR "Files changed" tab. - Configuration surface is small: a single
.rumdl.tomlfor rule enable/disable, per-file-ignore globs, inline<!-- rumdl-disable -->directives, andextendsfor 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 inunified+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 complementscargo xtask docs repo-links, it does not replace it. - Disabled-rule creep. Each rule disabled in
.rumdl.tomlis 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
- 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.
- Auto-fix in CI. Should CI ever run
rumdl check --fixand commit back, or should--fixstay 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. - 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. - 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.