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 +3 -0
- rrdoctor/__main__.py +6 -0
- rrdoctor/baseline.py +81 -0
- rrdoctor/cli.py +556 -0
- rrdoctor/config.py +145 -0
- rrdoctor/fixers.py +281 -0
- rrdoctor/mcp_server.py +67 -0
- rrdoctor/models.py +244 -0
- rrdoctor/reporting/__init__.py +1 -0
- rrdoctor/reporting/agent.py +132 -0
- rrdoctor/reporting/appendix.py +231 -0
- rrdoctor/reporting/badge.py +81 -0
- rrdoctor/reporting/json_report.py +13 -0
- rrdoctor/reporting/markdown.py +108 -0
- rrdoctor/reporting/sarif.py +67 -0
- rrdoctor/rules/__init__.py +1 -0
- rrdoctor/rules/base.py +128 -0
- rrdoctor/rules/ci.py +91 -0
- rrdoctor/rules/citation.py +75 -0
- rrdoctor/rules/data.py +130 -0
- rrdoctor/rules/environment.py +323 -0
- rrdoctor/rules/experiments.py +186 -0
- rrdoctor/rules/governance.py +88 -0
- rrdoctor/rules/license.py +28 -0
- rrdoctor/rules/notebooks.py +248 -0
- rrdoctor/rules/paths.py +77 -0
- rrdoctor/rules/project_metadata.py +75 -0
- rrdoctor/rules/readme.py +141 -0
- rrdoctor/rules/registry.py +55 -0
- rrdoctor/rules/release.py +80 -0
- rrdoctor/rules/security.py +90 -0
- rrdoctor/rules/tests.py +66 -0
- rrdoctor/scanner.py +132 -0
- rrdoctor/scoring.py +44 -0
- rrdoctor/verification.py +262 -0
- rrdoctor-0.2.1.dist-info/METADATA +321 -0
- rrdoctor-0.2.1.dist-info/RECORD +40 -0
- rrdoctor-0.2.1.dist-info/WHEEL +4 -0
- rrdoctor-0.2.1.dist-info/entry_points.txt +2 -0
- rrdoctor-0.2.1.dist-info/licenses/LICENSE +21 -0
rrdoctor/__init__.py
ADDED
rrdoctor/__main__.py
ADDED
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))
|