ADR-0866: Wire markdownlint-cli2 into make lint + pre-commit + CI¶
- Status: Accepted
- Date: 2026-05-30
- Deciders: lusoris
- Tags: ci, docs, lint, hygiene
Context¶
ADR-0864 (PR #332) tuned .markdownlint.json for the fork's prose-heavy docs corpus — disabling 17 noisy or unsafe-to-autofix rules and running markdownlint-cli2 --fix on the safe subset. After that sweep the remaining warning tail across docs/ + changelog.d/ + top-level markdown is ~6.2k (from a ~19.7k baseline).
ADR-0864 stopped at the config + cleanup. It did not wire the linter into any gate:
make lintcalls onlyclang-tidy,cppcheck,ruff,shellcheck, and the fragment-tree drift check. No markdown step..pre-commit-config.yamlhas formatters and security hooks but nomarkdownlint-cli2hook..github/workflows/lint-and-format.ymlhaspre-commit,clang-tidy,cppcheck,python-lint,docs-lint(mkdocs),shellcheck— nomarkdownlintjob.
Without a gate, future PRs reintroduce the same classes of drift the sweep just discharged. The touched-file lint-clean rule (CLAUDE.md §12 r12) only bites when a linter is actually wired up.
The pre-existing ~6.2k warnings forbid a naive all-files gate: every docs-adjacent PR would inherit a wall of red from neighbours it didn't touch. The only workable shape is the touched-file scope — identical to how the C lint pipeline handles clang-tidy (delta against the PR base ref, not the full tree).
Decision¶
Wire markdownlint-cli2 into all three surfaces, with touched-file scope as the default so the pre-existing tail does not gate PRs that don't touch it:
-
Makefile— add alint-mdphony target. Default scope is the delta againstorigin/master(touched markdown only). Override withMDLINT_SCOPE=allto run against the full corpus (docs/**/*.md changelog.d/**/*.md README.md CLAUDE.md AGENTS.md).lintdepends onlint-md. -
.pre-commit-config.yaml— add the officialmarkdownlint-cli2repo hook (https://github.com/DavidAnson/markdownlint-cli2) withpass_filenames: trueso it lints only staged markdown. Noargs: [--fix]— ADR-0864 documents 7 default rules that silently change meaning under autofix (MD004 prose conjunctions, MD018 PR-number headings, MD029 ordered-list renumbering, MD037/MD038 emphasis/code spacing, MD007 reference-list indent, MD027 license-block quoting). -
.github/workflows/lint-and-format.yml— add amarkdownlintjob that mirrors theclang-tidyjob's delta collection (PR / push / dispatch fan-out). Usesactions/setup-node@v4+npx --yes markdownlint-cli2. Runs the linter on the changed markdown set only; empty set short-circuits with success.
The wiring lands after PR #332. PR #332 ships the tuned config + initial sweep (111 files); this PR ships only the gate around it.
Auto-generated files and concat-script inputs excluded:
docs/adr/README.md+CHANGELOG.md— mechanically rendered fromdocs/adr/_index_fragments/andchangelog.d/byscripts/docs/concat-adr-index.sh/scripts/release/concat-changelog-fragments.sh(ADR-0221). Linting the rendered output would surface pre-existing table-column-count warnings (MD056) in concatenated rows that cannot be fixed by editing the output.docs/adr/_index_fragments/*.md— each fragment is a bare table row (no first-line H1, single line > 80 cols by design).changelog.d/<section>/*.md— each fragment is body-only with a## Sectionheading (no first-line H1) per ADR-0221.
These fragments are pipeline inputs, not standalone documents. The rendered output is excluded (auto-generated) and the inputs are excluded (structurally trip MD013/MD041/MD056 by design). Sweep PRs that touch the rendered output are unnecessary — the concatenation is deterministic.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| (Chosen) Touched-file scope by default, all-files via env opt-in | Honours CLAUDE.md §12 r12 (touched-file lint-clean); innocent PRs don't inherit ~6.2k pre-existing tail; all-files mode still available for full audits | Two scope modes to document | Pragmatic — matches the existing clang-tidy job's delta-scoping pattern |
| All-files gate, fail on ~6.2k warnings | Maximally enforces the tuned config | Every docs-adjacent PR red on day 1; touched-file rule unsatisfiable | Adversarial; would block the merge train |
| All-files gate, allow-list-existing-warnings file | Forces zero-net-new warnings without ignoring debt | Allow-list file churns on every cleanup PR; merge-conflict factory | Maintenance burden outweighs the precision win |
Advisory-only (continue-on-error: true) | No risk of false positives blocking PRs | No teeth — drift accrues silently; the entire reason to wire it in is to gate | Defeats the purpose |
| Skip wiring entirely, lean on PR review | Zero infra cost | Human review misses lint drift consistently; the ADR-0864 sweep was the proof | The status quo is what created the 19.7k baseline in the first place |
Consequences¶
- Positive:
- Touched-file lint-clean rule (CLAUDE.md §12 r12) now extends to markdown without dragging in pre-existing warnings.
- Future drift caught at the same gate that catches C/Python drift — same mental model for contributors.
make lint-md MDLINT_SCOPE=allremains available for periodic full-tree audits and follow-up sweep PRs.- Negative:
- One more job in
lint-and-format.yml(~10 s). - Pre-existing ~6.2k warnings still live in the tree; only new drift on touched files is gated. Follow-up sweep PRs can chip away at the tail.
- Neutral / follow-ups:
- PR #332 must land first (this PR's gate is meaningless against master's untuned config — it would report ~19.7k against any touched docs file).
- Future sweep PRs that discharge tail warnings should append a
changelog.d/changed/markdown-lint-sweep-NNNN.mdfragment.
References¶
- ADR-0864 —
.markdownlint.jsontune + initial sweep (PR #332). - CLAUDE.md §12 rule 12 — touched-file lint-clean rule (ADR-0141, ADR-0278).
markdownlint-cli2upstream: https://github.com/DavidAnson/markdownlint-cli2.- Related PRs: lands after #332.
- Source:
req— direct user direction to wire the linter intomake lint+ pre-commit + CI after PR #332's sweep.