from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
MARKDOWNLINT_REPO = "https://github.com/DavidAnson/markdownlint.git"
MARKDOWNLINT_REF = "v0.41.1"
MARKDOWNLINT_LINE = re.compile(
r"^(?P<file>.+?):(?P<line>\d+)(?::\d+)?\s+(?:error|warning)\s+(?P<rule>MD\d{3})\b"
)
Finding = tuple[str, int, str]
@dataclass
class ToolRun:
findings: set[Finding]
failures: list[str]
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
def ensure_corpus(corpus: Path, cache: Path) -> Path:
if corpus is not None:
if not corpus.is_dir():
sys.exit(f"corpus directory does not exist: {corpus}")
return corpus
checkout = cache / "markdownlint"
if not checkout.is_dir():
cache.mkdir(parents=True, exist_ok=True)
print(f"Cloning markdownlint {MARKDOWNLINT_REF} into {checkout} ...")
result = run(
[
"git", "clone", "--depth", "1",
"--branch", MARKDOWNLINT_REF,
MARKDOWNLINT_REPO, str(checkout),
]
)
if result.returncode != 0:
sys.exit(f"failed to clone markdownlint:\n{result.stderr}")
fixtures = checkout / "test"
if not fixtures.is_dir():
sys.exit(f"expected fixtures at {fixtures}, but that directory is missing")
return fixtures
def markdownlint_rules(ml_cmd: list[str]) -> set[str]:
candidates: list[Path] = []
binary = Path(ml_cmd[0])
for parent in binary.resolve().parents:
if parent.name == "node_modules":
candidates.append(parent)
break
candidates.append(Path("node_modules"))
candidates.append(Path.cwd() / "node_modules")
for node_modules in candidates:
rules_doc = node_modules / "markdownlint" / "doc" / "Rules.md"
if rules_doc.is_file():
return set(re.findall(r"^#+ .*?(MD\d{3})", rules_doc.read_text(), re.MULTILINE))
return set()
def rumdl_rules(rumdl: str) -> set[str]:
result = run([rumdl, "rule", "--output-format", "json"])
if result.returncode != 0:
return set()
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
return set()
entries = payload if isinstance(payload, list) else payload.get("rules", [])
found = set()
for entry in entries:
if not isinstance(entry, dict):
continue
code = entry.get("code")
if isinstance(code, str) and re.fullmatch(r"MD\d{3}", code):
found.add(code)
return found
def collect_rumdl(rumdl: str, files: list[Path], root: Path) -> ToolRun:
findings: set[Finding] = set()
failures: list[str] = []
for path in files:
result = run(
[
rumdl, "check",
"--no-config", "--no-cache",
"--output-format", "json",
path.name,
],
cwd=root,
)
if result.returncode > 1:
failures.append(f"{path.name}: rumdl exit {result.returncode}: {result.stderr.strip()[:200]}")
continue
if not result.stdout.strip():
continue
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
failures.append(f"{path.name}: rumdl emitted unparseable JSON")
continue
for item in payload:
findings.add((path.name, int(item["line"]), item["rule"]))
return ToolRun(findings, failures)
def collect_markdownlint(cmd: list[str], files: list[Path], root: Path, empty_config: Path) -> ToolRun:
findings: set[Finding] = set()
failures: list[str] = []
for path in files:
result = run(cmd + ["--config", str(empty_config), path.name], cwd=root)
stream = result.stdout + result.stderr
matched_any = False
for line in stream.splitlines():
match = MARKDOWNLINT_LINE.match(line.strip())
if match:
matched_any = True
findings.add((Path(match["file"]).name, int(match["line"]), match["rule"]))
if result.returncode == 1 and not matched_any:
failures.append(f"{path.name}: markdownlint reported violations but none parsed")
elif result.returncode > 1:
failures.append(f"{path.name}: markdownlint exit {result.returncode}")
return ToolRun(findings, failures)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--corpus", type=Path, default=None, help="Directory of .md fixtures (default: clone markdownlint)")
parser.add_argument("--cache", type=Path, default=Path("target/parity"), help="Where to cache the markdownlint checkout")
parser.add_argument("--rumdl", default="target/release/rumdl", help="Path to the rumdl binary")
parser.add_argument("--markdownlint", default="npx --yes markdownlint-cli2", help="markdownlint-cli2 command")
parser.add_argument("--limit", type=int, default=0, help="Only compare the first N fixtures (0 = all)")
parser.add_argument("--min-agreement", type=float, default=None, help="Fail if the agreement percentage drops below this")
parser.add_argument("--json", action="store_true", help="Emit machine-readable results")
args = parser.parse_args()
rumdl = args.rumdl
if Path(rumdl).is_file():
rumdl = str(Path(rumdl).resolve())
elif shutil.which(rumdl) is not None:
rumdl = shutil.which(rumdl)
else:
sys.exit(f"rumdl binary not found: {rumdl} (build it first, e.g. `cargo build --release`)")
ml_cmd = args.markdownlint.split()
if Path(ml_cmd[0]).is_file():
ml_cmd[0] = str(Path(ml_cmd[0]).resolve())
elif shutil.which(ml_cmd[0]) is None:
sys.exit(f"markdownlint command not found: {ml_cmd[0]}")
corpus = ensure_corpus(args.corpus, args.cache)
files = sorted(p for p in corpus.glob("*.md") if p.is_file())
if args.limit:
files = files[: args.limit]
if not files:
sys.exit(f"no .md fixtures found in {corpus}")
empty_config = args.cache / "empty.markdownlint-cli2.jsonc"
empty_config.parent.mkdir(parents=True, exist_ok=True)
empty_config.write_text("{}\n")
print(f"Comparing {len(files)} fixtures from {corpus}")
rumdl_run = collect_rumdl(rumdl, files, corpus)
ml_run = collect_markdownlint(ml_cmd, files, corpus, empty_config.resolve())
shared = rumdl_rules(rumdl) & markdownlint_rules(ml_cmd)
if not shared:
sys.exit(
"could not determine which rules both tools implement "
"(need `rumdl rule --output-format json` and node_modules/markdownlint). "
"Refusing to report a misleading agreement count."
)
ours = {f for f in rumdl_run.findings if f[2] in shared}
theirs = {f for f in ml_run.findings if f[2] in shared}
agreed = ours & theirs
only_ours = ours - theirs
only_theirs = theirs - ours
excluded = {f[2] for f in rumdl_run.findings if f[2] not in shared}
def by_rule(bucket: set[Finding]) -> list[tuple[str, int]]:
counts = Counter(f[2] for f in bucket)
return sorted(counts.items(), key=lambda item: (-item[1], item[0]))
total = len(agreed) + len(only_ours) + len(only_theirs)
pct = (100.0 * len(agreed) / total) if total else 100.0
if args.json:
print(json.dumps({
"fixtures": len(files),
"shared_rules": len(shared),
"agreed": len(agreed),
"rumdl_only": len(only_ours),
"markdownlint_only": len(only_theirs),
"agreement_pct": round(pct, 1),
"rumdl_only_by_rule": by_rule(only_ours),
"markdownlint_only_by_rule": by_rule(only_theirs),
"rules_not_compared": sorted(excluded),
"failures": rumdl_run.failures + ml_run.failures,
}, indent=2))
else:
print(f"\n agreed {len(agreed)}")
print(f" rumdl only {len(only_ours)}")
print(f" markdownlint only {len(only_theirs)}")
print(f" agreement {pct:.1f}% over {len(shared)} shared rules\n")
for label, bucket in (("rumdl only", only_ours), ("markdownlint only", only_theirs)):
counts = by_rule(bucket)[:10]
if counts:
print(f" top {label} rules: " + ", ".join(f"{r}={n}" for r, n in counts))
if excluded:
print(f"\n not compared (rumdl-only rules): {', '.join(sorted(excluded))}")
failures = rumdl_run.failures + ml_run.failures
if failures:
print(f"\n {len(failures)} file(s) failed to compare:", file=sys.stderr)
for failure in failures[:20]:
print(f" {failure}", file=sys.stderr)
return 2
if args.min_agreement is not None and pct < args.min_agreement:
print(
f"\nagreement {pct:.1f}% is below the required {args.min_agreement}%",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())