| title | Adding a peer linter |
|---|---|
| summary | How to wire a new peer Markdown linter into mdsmith's comparison docs, the per-rule coverage matrix, and the benchmark page. |
mdsmith ships a peer-linter coverage matrix and a
prose comparison page. Both stay accurate because
every rule README owns its own peer-mapping front
matter; the matrix is regenerated from those blocks.
Adding a new peer — say newtool — touches the
schema, the Go decoder, the matrix templates, every
rule README, the prose comparison page, and the
benchmark page.
This page is the checklist. Each step lists the files to touch and the validation that proves the step landed.
Two proto files declare what front-matter keys a rule README is allowed to carry:
internal/rules/proto.md— applied to most MDS rulesinternal/rules/directive-proto.md— applied to the directive rules (catalog, include, toc, build)
Add a newtool: line to both. The schema fragment
matches the existing peers: an open list of
{id, name, partial?, default} objects. Pick the
ID regex that matches the peer's naming:
- markdownlint-derived tools:
^MD[0-9]{3}$ - kebab-case schemes (panache, obsidian-linter,
most plugins):
^[a-z][a-z0-9-]*$
Update the contributor comment block in
proto.md so it names the new key alongside the
existing four.
internal/rules/ruledocs.go parses the front
matter into RuleInfo. Three edits:
- Add
Newtool []RuleMappingto theRuleInfostruct - Add the matching field to the anonymous struct
inside
parseFrontMatterwith the YAML tagyaml:"newtool" - Copy
meta.Newtoolintoinfo.Newtoolat the end ofparseFrontMatter
go build ./... should pass.
Each rule README links its peer rules in its
Meta-Information section. The test
internal/rules/peerlinks_test.go builds that block.
Teach it the new peer's links:
- Add
{"newtool", info.Newtool}topeerLinkSpecs - Add a
peerRefIDcase (a label prefix likemdl-, or the bare rule name for a shortcut peer) - Add a
peerRefURLcase returning the per-rule doc URL; if the tool has no per-rule page, point every entry at one section anchor (asmadodoes) - For a tool that pages rules by category (like
obsidian-linter), add a name-to-category map and a
peerEntryshortcut branch viaisShortcutPeer
The matrix lives in
docs/research/markdownlint-coverage/README.md. Most
sections are a <?catalog?> block per rule category,
each with a header: table and a row-expr: CUE
template that renders one cell per peer from the rule
README front matter. The category: "directive"
section is the exception: it is mdsmith-only, a
two-column mdsmith | What it adds table with no peer
cells. There is no dedicated Go coverage-matrix
generator to extend anymore; mdsmith fix renders
the <?catalog?> blocks from these templates.
Add the newtool column to every peer-coverage block
(all sections except the directive one), two edits
each:
- Add
newtoolto theheader:block: extend both the column-names row and the alignment row of dashes (| --- |) below it. - Append a peer cell to
row-expr:. Copy an existing peer's cell and read the new key:for m in newtoolwhen the key is a bare identifier, or thefm["..."]accessor when it has a hyphen — the wayobsidian-linteris read asfor m in fm["obsidian-linter"].
A peer cell renders — for an empty list, otherwise
a comma-joined entry per mapping: the peer id, a
✅/⚪ upstream-default marker, the name when it
differs from the id, and a (partial) suffix.
Keep the new cell identical to the others so the
legend holds.
The page's summary: and opening paragraph name the
peers in prose; add newtool to both so they match
the columns.
Step 5 regenerates the tables, and mdsmith check
fails on any that drift.
Every README under internal/rules/MDS*/ needs a
newtool: block in its front matter. Most get
newtool: []; only the rules that genuinely cover
a peer rule get a populated list.
A one-off Python script is the cheapest way to seed
the empties. Walk internal/rules/MDS*/README.md,
insert the block before the closing ---, and skip
files that already declare the key. Then hand-edit
the rules that need a real mapping. Record the
peer's upstream default-enabled state on each
entry.
Two facts about defaults are easy to miss:
- markdownlint, rumdl, and mado mostly ship their rules enabled by default. Check the upstream config doc per rule.
- obsidian-linter ships every rule disabled by
default — the plugin's
BooleanOptionsetsenabled: falsefor all 65 rules. New plugin tools often follow the same opt-in pattern.
mdsmith check internal/rules/ validates each rule
README against the proto schema. Run it after every
batch of edits.
go run ./cmd/mdsmith fix docs/research/markdownlint-coverage/
MDSMITH_UPDATE_PEER_LINKS=1 go test ./internal/rules \
-run TestRuleREADMEPeerLinks
go run ./cmd/mdsmith check internal/rules/ docs/research/
go test ./internal/release/ ./internal/rules/mdsmith fix rebuilds the <?catalog?> tables in
place from the rule README front matter. The
MDSMITH_UPDATE_PEER_LINKS run rewrites the
peer-linter bullets in each rule README's
Meta-Information section from the same front matter;
a plain go test ./internal/rules fails when a
README has drifted. Commit both diffs in the same
change as the front-matter edits. mdsmith check
fails on a table left out of sync with the rule
READMEs.
docs/background/markdown-linters.md is the
prose-style overview. Add a ### [newtool][]
subsection between the existing entries:
- One-line characterisation of the tool
- 4–6 bullets covering rule count, config format, autofix model, and CLI / CI / LSP availability
- A two-column comparison table against mdsmith
- A "When to Use What" entry near the bottom of the page
- The link reference at the very bottom
If the new tool covers ground no other peer
touches, add a #### newtool rules with no mdsmith equivalent subsection. Group the rules by theme
and keep each bullet short — MDS023 flags long
paragraphs, and a comma-separated list of 20
rule names is one long sentence.
Read the peer's README before writing the one-line summary. Strong READMEs share a pattern:
- Lead with one sentence, then a number. mado opens with a benchmark table before any feature list. That ordering tells the reader whether to keep reading in three seconds.
- Name the inspiration and the promise up front. rumdl opens with a single positioning line, names ruff as its model, and states the drop-in promise before any feature detail. A reader who knows ruff immediately grasps the trade-off.
- One precise sentence per job. panache's tagline names three jobs and one technical edge (the lossless CST parser) in a single sentence. Readers who do not care about Quarto stop reading; readers who do have everything they need to proceed.
When mdsmith's own README entry differs — the one-line tagline, the reproducible benchmark number, the note that it covers more per file than the Rust linters — record why. The contrast is the differentiator, and the comparison page is the right place to state it.
The harness expects a static binary. hyperfine
invokes it N times against a corpus directory.
The tool list does not live in
docs/research/benchmarks/run.sh — that script is
a thin wrapper over mdsmith-release bench. It
lives in internal/release/bench.go. If newtool
ships a static binary, wire it in:
- Pin it in the
benchToolsmanifest: the GitHub release.tar.gzURL (the version tag must appear in the URL path) and the SHA-256 of the tarball. Record the digest by downloading the tarball once and cross-check it against the publisher's checksums file when one exists.validateBenchManifestrejects a half-specified entry. The entry'sNamemust equal both the executable's basename inside the tarball and the hyperfine--command-name. - Add the command line to
runHyperfinenext to the other comparison tools, on the tool's defaults. Disable its on-disk cache if it has one (rumdl --no-cacheis the precedent). - Extend
TestBenchManifestInvariantsso the pin cannot be dropped silently.
gen_fragments.py needs no edit: it renders one
table row per command found in the hyperfine JSON
(only mado, the ratio base, is required by name).
Merging the wiring does not change the committed
numbers. data/*.json only moves when a maintainer
re-runs the harness via run.sh and reviews the
diff in a PR, because cross-tool ratios are only
valid within a single run on one machine. From the
merge onward the per-merge benchmark.yml run and
the per-release benchmark-publish job measure the
new tool; the in-repo tables gain its row at the
next deliberate refresh. Add a short subsection to
docs/research/benchmarks/README.md saying exactly
that, so the missing row reads as pending, not
overlooked.
If the tool cannot run that way — e.g. an editor
plugin without a CLI — skip the harness. Write a
short "Why newtool is not benchmarked" subsection in
docs/research/benchmarks/README.md instead. Name
the structural reason: a plugin runtime that is
not AOT-compiled, defaults that produce a no-op
run, or a corpus shape (Quarto, MDX) that doesn't
match the harness.
Add a bullet to CLAUDE.md under the project
docs catalog. Future agent runs find this page
when asked to wire in another peer linter.
The repo is the worked example. The PR that added obsidian-linter touched, in order:
internal/rules/proto.mdanddirective-proto.mdinternal/rules/ruledocs.go- 67 rule READMEs
- the regenerated
docs/research/markdownlint-coverage/README.md docs/background/markdown-linters.mddocs/research/benchmarks/README.md- this page
CLAUDE.md