rrdoctor 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rrdoctor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Research Repo Doctor package."""
2
+
3
+ __version__ = "0.2.0"
rrdoctor/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run the rrdoctor CLI with ``python -m rrdoctor``."""
2
+
3
+ from rrdoctor.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
rrdoctor/baseline.py ADDED
@@ -0,0 +1,81 @@
1
+ """Compare a current scan against a stored baseline report.
2
+
3
+ This enables PR automation that gates only on *newly introduced* findings, so a
4
+ team can adopt the audit on a large legacy repository without having to fix
5
+ everything before the first green build.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from rrdoctor.models import DiffResult, Finding, ScanReport
15
+
16
+
17
+ def load_baseline_fingerprints(path: Path) -> tuple[set[str], int | None]:
18
+ """Load finding fingerprints and score from a baseline JSON report.
19
+
20
+ The baseline is a report produced by ``rrdoctor scan --format json``. Only the
21
+ rule id and location are used, so baselines stay stable across versions.
22
+ """
23
+
24
+ data: Any = json.loads(path.read_text(encoding="utf-8"))
25
+ if not isinstance(data, dict):
26
+ raise ValueError(f"Baseline must be a JSON object: {path}")
27
+ fingerprints: set[str] = set()
28
+ for finding in data.get("findings", []):
29
+ if not isinstance(finding, dict):
30
+ continue
31
+ rule_id = str(finding.get("rule_id", ""))
32
+ file = finding.get("file") or ""
33
+ line = finding.get("line")
34
+ line_part = str(line) if line else ""
35
+ fingerprints.add(f"{rule_id}|{file}|{line_part}")
36
+ score = data.get("score")
37
+ return fingerprints, score if isinstance(score, int) else None
38
+
39
+
40
+ def diff_against_baseline(report: ScanReport, baseline_path: Path) -> DiffResult:
41
+ """Return the difference between a current report and a baseline report."""
42
+
43
+ baseline_fps, baseline_score = load_baseline_fingerprints(baseline_path)
44
+ current_fps = {finding.fingerprint for finding in report.findings}
45
+
46
+ new: list[Finding] = []
47
+ unchanged: list[Finding] = []
48
+ for finding in report.findings:
49
+ if finding.fingerprint in baseline_fps:
50
+ unchanged.append(finding)
51
+ else:
52
+ new.append(finding)
53
+
54
+ fixed_fps = baseline_fps - current_fps
55
+ fixed = [_placeholder_finding(fp) for fp in sorted(fixed_fps)]
56
+
57
+ return DiffResult(
58
+ new=new,
59
+ fixed=fixed,
60
+ unchanged=unchanged,
61
+ baseline_score=baseline_score,
62
+ current_score=report.score,
63
+ )
64
+
65
+
66
+ def _placeholder_finding(fingerprint: str) -> Finding:
67
+ """Reconstruct a minimal finding from a baseline fingerprint for reporting."""
68
+
69
+ from rrdoctor.models import Category, Severity
70
+
71
+ rule_id, file, line = [*fingerprint.split("|"), "", ""][:3]
72
+ return Finding(
73
+ id=f"{rule_id}-fixed",
74
+ rule_id=rule_id,
75
+ title="(resolved since baseline)",
76
+ category=Category.METADATA,
77
+ severity=Severity.INFO,
78
+ message="This finding was present in the baseline and is no longer reported.",
79
+ file=file or None,
80
+ line=int(line) if line.isdigit() else None,
81
+ )
rrdoctor/cli.py ADDED
@@ -0,0 +1,556 @@
1
+ """Typer command-line interface for Research Repo Doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from rrdoctor import __version__
17
+ from rrdoctor.baseline import diff_against_baseline
18
+ from rrdoctor.config import (
19
+ FORMAT_VALUES,
20
+ PROFILES,
21
+ apply_cli_overrides,
22
+ default_config_text,
23
+ load_config,
24
+ )
25
+ from rrdoctor.fixers import FixContext, apply_fix, fixable_rule_ids
26
+ from rrdoctor.models import DiffResult, ScanReport
27
+ from rrdoctor.reporting.agent import render_agent_json, render_agent_markdown
28
+ from rrdoctor.reporting.appendix import render_appendix, render_checklist
29
+ from rrdoctor.reporting.badge import render_badge_endpoint, render_badge_svg
30
+ from rrdoctor.reporting.json_report import render_json
31
+ from rrdoctor.reporting.markdown import render_markdown
32
+ from rrdoctor.reporting.sarif import render_sarif
33
+ from rrdoctor.rules.registry import all_rules, get_rule
34
+ from rrdoctor.scanner import Scanner
35
+ from rrdoctor.verification import render_verification, verification_failed
36
+
37
+ app = typer.Typer(
38
+ help="Audit research repositories for reproducibility readiness.", no_args_is_help=True
39
+ )
40
+ console = Console()
41
+ err_console = Console(stderr=True)
42
+
43
+
44
+ def _parse_rule_ids(raw: str | None) -> set[str]:
45
+ if not raw:
46
+ return set()
47
+ return {item.strip().upper() for item in raw.split(",") if item.strip()}
48
+
49
+
50
+ def _render(report: ScanReport, output_format: str) -> str:
51
+ if output_format == "markdown":
52
+ return render_markdown(report)
53
+ if output_format == "json":
54
+ return render_json(report)
55
+ if output_format == "sarif":
56
+ return render_sarif(report)
57
+ if output_format == "agent":
58
+ return render_agent_markdown(report)
59
+ raise typer.BadParameter(f"Unsupported format: {output_format}")
60
+
61
+
62
+ def _print_summary(report: ScanReport) -> None:
63
+ table = Table(title="Research Repo Doctor Summary")
64
+ table.add_column("Metric")
65
+ table.add_column("Value", justify="right")
66
+ table.add_row("Profile", report.profile)
67
+ table.add_row("Score", f"{report.score}/100")
68
+ table.add_row("Errors", str(report.summary["error"]))
69
+ table.add_row("Warnings", str(report.summary["warning"]))
70
+ table.add_row("Info", str(report.summary["info"]))
71
+ table.add_row("Rules evaluated", str(report.rules_evaluated))
72
+ err_console.print(table)
73
+
74
+
75
+ def _should_fail(report: ScanReport, fail_on: str) -> bool:
76
+ if fail_on == "none":
77
+ return False
78
+ if fail_on == "error":
79
+ return report.summary["error"] > 0
80
+ if fail_on == "warning":
81
+ return report.summary["error"] > 0 or report.summary["warning"] > 0
82
+ return False
83
+
84
+
85
+ def _print_diff(diff: DiffResult) -> None:
86
+ table = Table(title="Baseline comparison")
87
+ table.add_column("Change")
88
+ table.add_column("Count", justify="right")
89
+ table.add_row("New findings", str(len(diff.new)))
90
+ table.add_row("Fixed findings", str(len(diff.fixed)))
91
+ table.add_row("Unchanged findings", str(len(diff.unchanged)))
92
+ base = "n/a" if diff.baseline_score is None else str(diff.baseline_score)
93
+ table.add_row("Score", f"{base} -> {diff.current_score}")
94
+ err_console.print(table)
95
+ for finding in diff.new:
96
+ err_console.print(f"[red]NEW[/red] {finding.rule_id} {finding.title}")
97
+
98
+
99
+ def _diff_should_fail(diff: DiffResult, fail_on_new: str) -> bool:
100
+ if fail_on_new == "none":
101
+ return False
102
+ levels = {finding.severity.value for finding in diff.new}
103
+ if fail_on_new == "error":
104
+ return "error" in levels
105
+ if fail_on_new == "warning":
106
+ return "error" in levels or "warning" in levels
107
+ return False
108
+
109
+
110
+ def _build_report(path: Path, profile: str, config: Path | None = None) -> ScanReport:
111
+ """Load config, apply the profile, and scan a path into a report."""
112
+
113
+ loaded = load_config(config)
114
+ effective = apply_cli_overrides(loaded, profile=profile)
115
+ return Scanner(effective).scan(path)
116
+
117
+
118
+ def _write_or_echo(rendered: str, output: Path | None, label: str) -> None:
119
+ if output:
120
+ output.parent.mkdir(parents=True, exist_ok=True)
121
+ output.write_text(rendered, encoding="utf-8")
122
+ err_console.print(f"Wrote {label} to [bold]{output}[/bold]")
123
+ else:
124
+ typer.echo(rendered)
125
+
126
+
127
+ @app.command()
128
+ def scan(
129
+ path: Annotated[Path, typer.Argument(help="Repository path to scan.")] = Path("."),
130
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
131
+ output_format: Annotated[
132
+ str,
133
+ typer.Option(
134
+ "--format",
135
+ help="Report format: markdown, json, sarif, or agent.",
136
+ rich_help_panel="Report",
137
+ ),
138
+ ] = "markdown",
139
+ output: Annotated[
140
+ Path | None, typer.Option("--output", help="Write report to this path.")
141
+ ] = None,
142
+ fail_on: Annotated[
143
+ str, typer.Option("--fail-on", help="Failure threshold: none, error, warning.")
144
+ ] = "error",
145
+ fail_on_new: Annotated[
146
+ str | None,
147
+ typer.Option(
148
+ "--fail-on-new",
149
+ help="With --baseline, fail only on newly introduced findings (none/error/warning).",
150
+ ),
151
+ ] = None,
152
+ baseline: Annotated[
153
+ Path | None,
154
+ typer.Option(
155
+ "--baseline", help="Compare against a JSON report and show new/fixed findings."
156
+ ),
157
+ ] = None,
158
+ profile: Annotated[
159
+ str, typer.Option("--profile", help="Profile: minimal, standard, strict, ml.")
160
+ ] = "standard",
161
+ include: Annotated[
162
+ str | None, typer.Option("--include", help="Comma-separated rule IDs to include.")
163
+ ] = None,
164
+ exclude: Annotated[
165
+ str | None, typer.Option("--exclude", help="Comma-separated rule IDs to exclude.")
166
+ ] = None,
167
+ quiet: Annotated[bool, typer.Option("--quiet", help="Suppress Rich summary table.")] = False,
168
+ verbose: Annotated[bool, typer.Option("--verbose", help="Print scanner details.")] = False,
169
+ ) -> None:
170
+ """Scan a repository and emit a structured report."""
171
+
172
+ if output_format not in FORMAT_VALUES:
173
+ raise typer.BadParameter(f"--format must be one of: {', '.join(FORMAT_VALUES)}")
174
+ if profile not in PROFILES:
175
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
176
+ if fail_on not in ("none", "error", "warning"):
177
+ raise typer.BadParameter("--fail-on must be one of: none, error, warning")
178
+ if fail_on_new is not None and fail_on_new not in ("none", "error", "warning"):
179
+ raise typer.BadParameter("--fail-on-new must be one of: none, error, warning")
180
+ if fail_on_new is not None and baseline is None:
181
+ raise typer.BadParameter("--fail-on-new requires --baseline.")
182
+
183
+ loaded = load_config(config)
184
+ effective = apply_cli_overrides(
185
+ loaded,
186
+ profile=profile,
187
+ output_format=output_format,
188
+ output=str(output) if output else None,
189
+ fail_on=fail_on,
190
+ )
191
+ scanner = Scanner(effective, include=_parse_rule_ids(include), exclude=_parse_rule_ids(exclude))
192
+ report = scanner.scan(path)
193
+ rendered = _render(report, output_format)
194
+
195
+ if not quiet:
196
+ _print_summary(report)
197
+ if verbose:
198
+ console.print(f"Scanned repository: {report.repository_path}")
199
+
200
+ if output:
201
+ output.parent.mkdir(parents=True, exist_ok=True)
202
+ output.write_text(rendered, encoding="utf-8")
203
+ if not quiet:
204
+ err_console.print(f"Wrote {output_format} report to [bold]{output}[/bold]")
205
+ else:
206
+ typer.echo(rendered)
207
+
208
+ diff: DiffResult | None = None
209
+ if baseline is not None:
210
+ if not baseline.exists():
211
+ err_console.print(f"[red]Baseline not found:[/red] {baseline}")
212
+ raise typer.Exit(2)
213
+ diff = diff_against_baseline(report, baseline)
214
+ if not quiet:
215
+ _print_diff(diff)
216
+
217
+ if diff is not None and fail_on_new is not None:
218
+ if _diff_should_fail(diff, fail_on_new):
219
+ raise typer.Exit(1)
220
+ return
221
+
222
+ if _should_fail(report, fail_on):
223
+ raise typer.Exit(1)
224
+
225
+
226
+ @app.command()
227
+ def fix(
228
+ path: Annotated[Path, typer.Argument(help="Repository path to fix.")] = Path("."),
229
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
230
+ profile: Annotated[
231
+ str, typer.Option("--profile", help="Profile: minimal, standard, strict, ml.")
232
+ ] = "standard",
233
+ write: Annotated[
234
+ bool, typer.Option("--write", help="Apply fixes (default is a dry-run preview).")
235
+ ] = False,
236
+ only: Annotated[
237
+ str | None, typer.Option("--only", help="Comma-separated rule IDs to fix.")
238
+ ] = None,
239
+ project_name: Annotated[
240
+ str | None, typer.Option("--project-name", help="Project name for scaffolded files.")
241
+ ] = None,
242
+ author: Annotated[
243
+ str, typer.Option("--author", help="Author/copyright holder for scaffolded files.")
244
+ ] = "The Authors",
245
+ ) -> None:
246
+ """Apply deterministic, idempotent auto-fixes for common reproducibility gaps.
247
+
248
+ Only safe scaffolding is created (governance docs, citation metadata, data and
249
+ results provenance notes, changelog, and ignore entries). Existing files are
250
+ never overwritten. Run without --write to preview, then re-run with --write.
251
+ """
252
+
253
+ if profile not in PROFILES:
254
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
255
+
256
+ loaded = load_config(config)
257
+ effective = apply_cli_overrides(loaded, profile=profile)
258
+ scanner = Scanner(effective)
259
+ report = scanner.scan(path)
260
+ root = Path(path).resolve()
261
+
262
+ fixable = fixable_rule_ids()
263
+ only_ids = _parse_rule_ids(only)
264
+ target_ids: list[str] = []
265
+ for finding in report.findings:
266
+ rid = finding.rule_id
267
+ if rid in fixable and rid not in target_ids and (not only_ids or rid in only_ids):
268
+ target_ids.append(rid)
269
+
270
+ if not target_ids:
271
+ console.print("No auto-fixable findings. Run `rrdoctor plan` for the remaining work.")
272
+ return
273
+
274
+ ctx = FixContext(
275
+ root=root,
276
+ project_name=project_name or root.name,
277
+ author=author,
278
+ year=datetime.now(timezone.utc).year,
279
+ )
280
+
281
+ table = Table(title="rrdoctor fix" + ("" if write else " (dry-run)"))
282
+ table.add_column("Rule")
283
+ table.add_column("Action")
284
+ table.add_column("Detail")
285
+ applied = 0
286
+ for rid in target_ids:
287
+ if write:
288
+ result = apply_fix(rid, ctx)
289
+ if result is None:
290
+ continue
291
+ if result.action in ("created", "updated"):
292
+ applied += 1
293
+ table.add_row(result.rule_id, result.action, result.detail)
294
+ else:
295
+ rule = get_rule(rid)
296
+ detail = rule.definition.remediation if rule else ""
297
+ table.add_row(rid, "would fix", detail)
298
+ console.print(table)
299
+ if write:
300
+ err_console.print(
301
+ f"Applied {applied} fix(es). Re-run `rrdoctor scan` to verify, then review the diff."
302
+ )
303
+ else:
304
+ err_console.print("Dry-run only. Re-run with --write to apply these fixes.")
305
+
306
+
307
+ @app.command()
308
+ def plan(
309
+ path: Annotated[Path, typer.Argument(help="Repository path to plan.")] = Path("."),
310
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
311
+ profile: Annotated[
312
+ str, typer.Option("--profile", help="Profile: minimal, standard, strict, ml.")
313
+ ] = "standard",
314
+ output_format: Annotated[
315
+ str, typer.Option("--format", help="Plan format: markdown or json.")
316
+ ] = "markdown",
317
+ output: Annotated[
318
+ Path | None, typer.Option("--output", help="Write the plan to this path.")
319
+ ] = None,
320
+ ) -> None:
321
+ """Emit a tool-agnostic fix plan for any coding agent or human to execute."""
322
+
323
+ if profile not in PROFILES:
324
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
325
+ if output_format not in ("markdown", "json"):
326
+ raise typer.BadParameter("--format must be one of: markdown, json")
327
+
328
+ loaded = load_config(config)
329
+ effective = apply_cli_overrides(loaded, profile=profile)
330
+ report = Scanner(effective).scan(path)
331
+ rendered = (
332
+ render_agent_json(report) if output_format == "json" else render_agent_markdown(report)
333
+ )
334
+
335
+ if output:
336
+ output.parent.mkdir(parents=True, exist_ok=True)
337
+ output.write_text(rendered, encoding="utf-8")
338
+ err_console.print(f"Wrote plan to [bold]{output}[/bold]")
339
+ else:
340
+ typer.echo(rendered)
341
+
342
+
343
+ @app.command()
344
+ def badge(
345
+ path: Annotated[Path, typer.Argument(help="Repository path to score.")] = Path("."),
346
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
347
+ profile: Annotated[
348
+ str, typer.Option("--profile", help="Profile: minimal, standard, strict, ml.")
349
+ ] = "standard",
350
+ output_format: Annotated[
351
+ str, typer.Option("--format", help="Badge format: endpoint (Shields.io JSON) or svg.")
352
+ ] = "endpoint",
353
+ output: Annotated[
354
+ Path | None, typer.Option("--output", help="Write the badge to this path.")
355
+ ] = None,
356
+ ) -> None:
357
+ """Emit a reproducibility score badge (Shields.io endpoint JSON or SVG)."""
358
+
359
+ if profile not in PROFILES:
360
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
361
+ if output_format not in ("endpoint", "svg"):
362
+ raise typer.BadParameter("--format must be one of: endpoint, svg")
363
+
364
+ loaded = load_config(config)
365
+ effective = apply_cli_overrides(loaded, profile=profile)
366
+ report = Scanner(effective).scan(path)
367
+ rendered = render_badge_svg(report) if output_format == "svg" else render_badge_endpoint(report)
368
+
369
+ if output:
370
+ output.parent.mkdir(parents=True, exist_ok=True)
371
+ output.write_text(rendered, encoding="utf-8")
372
+ err_console.print(f"Wrote badge to [bold]{output}[/bold]")
373
+ else:
374
+ typer.echo(rendered)
375
+
376
+
377
+ @app.command()
378
+ def init(
379
+ profile: Annotated[
380
+ str, typer.Option("--profile", help="Profile for the generated config.")
381
+ ] = "standard",
382
+ output: Annotated[Path, typer.Option("--output", help="Config file path.")] = Path(
383
+ ".rrdoctor.yml"
384
+ ),
385
+ force: Annotated[
386
+ bool, typer.Option("--force", help="Overwrite an existing config file.")
387
+ ] = False,
388
+ ) -> None:
389
+ """Create a documented .rrdoctor.yml file."""
390
+
391
+ if profile not in PROFILES:
392
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
393
+ if output.exists() and not force:
394
+ err_console.print(f"[red]Refusing to overwrite existing config:[/red] {output}")
395
+ raise typer.Exit(1)
396
+ output.parent.mkdir(parents=True, exist_ok=True)
397
+ output.write_text(default_config_text(profile), encoding="utf-8")
398
+ console.print(f"Created {output}")
399
+
400
+
401
+ @app.command()
402
+ def verify(
403
+ path: Annotated[Path, typer.Argument(help="Repository path to verify.")] = Path("."),
404
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
405
+ profile: Annotated[
406
+ str, typer.Option("--profile", help="Profile to scan with (default: standard).")
407
+ ] = "standard",
408
+ run: Annotated[
409
+ bool,
410
+ typer.Option(
411
+ "--run",
412
+ help="Actually resolve deps (L2) and execute the entrypoint (L3). Runs repo code.",
413
+ ),
414
+ ] = False,
415
+ timeout: Annotated[
416
+ int, typer.Option("--timeout", help="Per-step timeout in seconds for --run.")
417
+ ] = 300,
418
+ output: Annotated[
419
+ Path | None, typer.Option("--output", help="Write the report to this path.")
420
+ ] = None,
421
+ fail_on: Annotated[
422
+ str, typer.Option("--fail-on", help="Failure threshold: none, error.")
423
+ ] = "error",
424
+ ) -> None:
425
+ """Run the reproducibility verification ladder (L1 static, L2 build, L3 run).
426
+
427
+ L1 is deterministic. With --run, L2 resolves dependencies and L3 executes a
428
+ declared entrypoint under a timeout (only use --run on repositories you trust).
429
+ """
430
+
431
+ if profile not in PROFILES:
432
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
433
+ if fail_on not in ("none", "error"):
434
+ raise typer.BadParameter("--fail-on must be one of: none, error")
435
+
436
+ report = _build_report(path, profile, config)
437
+ rendered = render_verification(report, Path(path).resolve(), run, timeout)
438
+ _write_or_echo(rendered, output, "verification report")
439
+
440
+ if fail_on == "error" and verification_failed(report):
441
+ raise typer.Exit(1)
442
+
443
+
444
+ @app.command()
445
+ def appendix(
446
+ path: Annotated[Path, typer.Argument(help="Repository path to scan.")] = Path("."),
447
+ config: Annotated[Path | None, typer.Option("--config", help="Path to .rrdoctor.yml.")] = None,
448
+ profile: Annotated[
449
+ str, typer.Option("--profile", help="Profile to scan with (default: acm).")
450
+ ] = "acm",
451
+ section: Annotated[
452
+ str,
453
+ typer.Option("--section", help="Which artifact to emit: appendix, checklist, or both."),
454
+ ] = "both",
455
+ output: Annotated[
456
+ Path | None, typer.Option("--output", help="Write output to this path.")
457
+ ] = None,
458
+ ) -> None:
459
+ """Generate an ACM-style Artifact Appendix and submission checklist mapping.
460
+
461
+ Maps findings to ACM Artifact Evaluation badges and the NeurIPS reproducibility
462
+ checklist so you can fill the artifact paperwork before a deadline.
463
+ """
464
+
465
+ if profile not in PROFILES:
466
+ raise typer.BadParameter(f"--profile must be one of: {', '.join(PROFILES)}")
467
+ if section not in ("appendix", "checklist", "both"):
468
+ raise typer.BadParameter("--section must be one of: appendix, checklist, both")
469
+
470
+ report = _build_report(path, profile, config)
471
+
472
+ parts: list[str] = []
473
+ if section in ("appendix", "both"):
474
+ parts.append(render_appendix(report))
475
+ if section in ("checklist", "both"):
476
+ parts.append(render_checklist(report))
477
+ rendered = "\n\n".join(parts)
478
+
479
+ _write_or_echo(rendered, output, "artifact appendix")
480
+
481
+
482
+ @app.command()
483
+ def mcp() -> None:
484
+ """Start the MCP server so coding agents can call scan/verify/appendix as tools.
485
+
486
+ Requires the optional dependency: pip install 'rrdoctor[mcp]'.
487
+ """
488
+
489
+ from rrdoctor.mcp_server import run as run_server
490
+
491
+ run_server()
492
+
493
+
494
+ @app.command("list-rules")
495
+ def list_rules() -> None:
496
+ """List registered rules."""
497
+
498
+ table = Table(title="Research Repo Doctor Rules")
499
+ table.add_column("ID")
500
+ table.add_column("Name")
501
+ table.add_column("Category")
502
+ table.add_column("Severity")
503
+ table.add_column("Profiles")
504
+ for rule in all_rules():
505
+ table.add_row(
506
+ rule.definition.id,
507
+ rule.definition.name,
508
+ rule.definition.category.value,
509
+ rule.definition.severity.value,
510
+ ", ".join(rule.definition.profiles),
511
+ )
512
+ console.print(table)
513
+
514
+
515
+ @app.command()
516
+ def explain(rule_id: Annotated[str, typer.Argument(help="Rule ID, for example RRD001.")]) -> None:
517
+ """Explain a rule and how to remediate it."""
518
+
519
+ rule = get_rule(rule_id)
520
+ if rule is None:
521
+ console.print(f"[red]Unknown rule:[/red] {rule_id}")
522
+ raise typer.Exit(1)
523
+ definition = rule.definition
524
+ console.print(f"[bold]{definition.id}: {definition.name}[/bold]")
525
+ console.print(f"Category: {definition.category.value}")
526
+ console.print(f"Severity: {definition.severity.value}")
527
+ console.print(f"Profiles: {', '.join(definition.profiles)}")
528
+ console.print("")
529
+ console.print(definition.description)
530
+ console.print("")
531
+ console.print(f"[bold]Why it matters[/bold]\n{definition.rationale}")
532
+ console.print("")
533
+ console.print(f"[bold]Remediation[/bold]\n{definition.remediation}")
534
+ if definition.examples:
535
+ console.print("")
536
+ console.print("[bold]Examples[/bold]")
537
+ for example in definition.examples:
538
+ console.print(f"- {example}")
539
+
540
+
541
+ @app.command()
542
+ def doctor() -> None:
543
+ """Run rrdoctor self-diagnostics."""
544
+
545
+ payload = {
546
+ "rrdoctor_version": __version__,
547
+ "python": sys.version.split()[0],
548
+ "cwd": str(Path.cwd()),
549
+ "cwd_readable": Path.cwd().exists(),
550
+ "optional_dependencies": {
551
+ "nbformat": importlib.util.find_spec("nbformat") is not None,
552
+ "yaml": importlib.util.find_spec("yaml") is not None,
553
+ "rich": importlib.util.find_spec("rich") is not None,
554
+ },
555
+ }
556
+ console.print_json(json.dumps(payload))