codedoctor 0.1.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 10
8
+
9
+ - package-ecosystem: "github-actions"
10
+ directory: "/"
11
+ schedule:
12
+ interval: "weekly"
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: ["main"]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+
14
+ - uses: actions/setup-python@v6
15
+ with:
16
+ python-version: "3.12"
17
+
18
+ - name: Install
19
+ run: |
20
+ python -m pip install --upgrade pip
21
+ pip install -e ".[dev]"
22
+
23
+ - name: Ruff
24
+ run: ruff check .
25
+
26
+ - name: Black
27
+ run: black . --check
28
+
29
+ - name: MyPy
30
+ run: mypy src
31
+
32
+ - name: Pytest
33
+ run: pytest -q
@@ -0,0 +1,72 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distributions
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v6
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v6
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install build tooling
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ python -m pip install build
28
+
29
+ - name: Build
30
+ run: |
31
+ python -m build
32
+
33
+ - name: Upload dist artifacts
34
+ uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/*
38
+
39
+ pypi:
40
+ name: Publish to PyPI (Trusted Publishing)
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ environment: pypi
44
+ permissions:
45
+ id-token: write
46
+ steps:
47
+ - name: Download dist artifacts
48
+ uses: actions/download-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist
52
+
53
+ - name: Publish
54
+ uses: pypa/gh-action-pypi-publish@release/v1
55
+
56
+ github-release:
57
+ name: Create GitHub Release + upload artifacts
58
+ needs: build
59
+ runs-on: ubuntu-latest
60
+ permissions:
61
+ contents: write
62
+ steps:
63
+ - name: Download dist artifacts
64
+ uses: actions/download-artifact@v4
65
+ with:
66
+ name: dist
67
+ path: dist
68
+
69
+ - name: Create GitHub Release
70
+ uses: softprops/action-gh-release@v2
71
+ with:
72
+ files: dist/*
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ .ruff_cache/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .codedoctor
6
+ src/codedoctor/__pycache__/
7
+ tests/__pycache__/
@@ -0,0 +1,12 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.8.0
4
+ hooks:
5
+ - id: ruff
6
+ args: ["--fix"]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/psf/black
10
+ rev: 26.1.0
11
+ hooks:
12
+ - id: black
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Faint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: codedoctor
3
+ Version: 0.1.0
4
+ Summary: Beginner-friendly codebase doctor: lint, format, type-check, security and tests.
5
+ Author-email: Patrick Faint <pattymayo3@icloud.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Provides-Extra: dev
10
+ Requires-Dist: bandit>=1.7.0; extra == 'dev'
11
+ Requires-Dist: black>=24.0.0; extra == 'dev'
12
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
13
+ Requires-Dist: pre-commit>=3.7.0; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # CodeDoctor - v0.1.0
19
+
20
+ **CodeDoctor** is a beginner-friendly Python CLI that scans a repository and
21
+ summarizes common quality checks (linting, formatting, typing, security, and
22
+ tests) in one readable report.
23
+
24
+ It can also generate a **TL;DR summary** at the top of the report and save
25
+ results to a `.txt` report file so you can compare runs over time.
26
+
27
+ ---
28
+
29
+ ## What it checks
30
+
31
+ Depending on what you have installed, CodeDoctor can run:
32
+
33
+ - **ruff** — linting (and optional auto-fixes)
34
+ - **black** — formatting (and optional formatting changes)
35
+ - **mypy** — static type checking
36
+ - **bandit** — basic security checks
37
+ - **pytest** — test runner
38
+
39
+ If a tool is missing, CodeDoctor will report it clearly.
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ### Option A: Install from PyPI (recommended once published)
46
+
47
+ ```bash
48
+ pip install codedoctor
49
+ ```
50
+
51
+ ### Option B: Install from source (for development)
52
+
53
+ ```bash
54
+ git clone https://github.com/BigPattyOG/CodeDoctor.git
55
+ cd CodeDoctor
56
+ python -m venv .venv
57
+ # Windows:
58
+ .venv\Scripts\activate
59
+ # macOS/Linux:
60
+ source .venv/bin/activate
61
+
62
+ python -m pip install -U pip
63
+ python -m pip install -e .
64
+ ```
65
+
66
+ To include developer tools (recommended for contributors):
67
+
68
+ ```bash
69
+ python -m pip install -e ".[dev]"
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Quick start
75
+
76
+ From inside any repo you want to scan:
77
+
78
+ ```bash
79
+ codedoctor scan .
80
+ ```
81
+
82
+ CodeDoctor prints a report to the terminal and also writes a report file under:
83
+
84
+ - `.codedoctor/report-latest.txt`
85
+ - `.codedoctor/report-prev.txt` (previous run)
86
+ - `.codedoctor/report-YYYYMMDD-HHMMSS.txt` (timestamped history)
87
+
88
+ ---
89
+
90
+ ## Example output (TL;DR)
91
+
92
+ ```text
93
+ CodeDoctor Report TL;DR
94
+ ----------------------
95
+ Overall: WARN
96
+ Checks: 6 passed / 1 warned / 0 failed / 7 total
97
+
98
+ Warnings:
99
+ - pytest (tests)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Commands
105
+
106
+ ### Scan a repo
107
+
108
+ ```bash
109
+ codedoctor scan .
110
+ ```
111
+
112
+ ### Apply safe auto-fixes (where supported)
113
+
114
+ ```bash
115
+ codedoctor scan . --fix
116
+ ```
117
+
118
+ This may run tools like:
119
+
120
+ - `ruff check . --fix`
121
+ - `black .`
122
+
123
+ ### Skip running tests
124
+
125
+ ```bash
126
+ codedoctor scan . --skip-tests
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Exit codes (for CI)
132
+
133
+ CodeDoctor uses simple exit codes so it can be used in CI:
134
+
135
+ - **0** — all checks PASS
136
+ - **1** — at least one WARN, and no FAIL
137
+ - **2** — one or more FAIL
138
+
139
+ ---
140
+
141
+ ## Notes (Windows + pytest warnings)
142
+
143
+ On Windows, `pytest` may sometimes print messages like:
144
+
145
+ - `Exception ignored in atexit callback`
146
+ - `PermissionError: [WinError 5] Access is denied`
147
+
148
+ Even if tests pass, CodeDoctor may classify the result as **WARN** so the run is
149
+ not marked as perfectly clean.
150
+
151
+ ---
152
+
153
+ ## Contributing
154
+
155
+ PRs welcome. A typical workflow:
156
+
157
+ ```bash
158
+ python -m pip install -e ".[dev]"
159
+ pre-commit run --all-files
160
+ pytest -q
161
+ ```
162
+
163
+ ---
164
+
165
+ ## License
166
+
167
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,150 @@
1
+ # CodeDoctor - v0.1.0
2
+
3
+ **CodeDoctor** is a beginner-friendly Python CLI that scans a repository and
4
+ summarizes common quality checks (linting, formatting, typing, security, and
5
+ tests) in one readable report.
6
+
7
+ It can also generate a **TL;DR summary** at the top of the report and save
8
+ results to a `.txt` report file so you can compare runs over time.
9
+
10
+ ---
11
+
12
+ ## What it checks
13
+
14
+ Depending on what you have installed, CodeDoctor can run:
15
+
16
+ - **ruff** — linting (and optional auto-fixes)
17
+ - **black** — formatting (and optional formatting changes)
18
+ - **mypy** — static type checking
19
+ - **bandit** — basic security checks
20
+ - **pytest** — test runner
21
+
22
+ If a tool is missing, CodeDoctor will report it clearly.
23
+
24
+ ---
25
+
26
+ ## Installation
27
+
28
+ ### Option A: Install from PyPI (recommended once published)
29
+
30
+ ```bash
31
+ pip install codedoctor
32
+ ```
33
+
34
+ ### Option B: Install from source (for development)
35
+
36
+ ```bash
37
+ git clone https://github.com/BigPattyOG/CodeDoctor.git
38
+ cd CodeDoctor
39
+ python -m venv .venv
40
+ # Windows:
41
+ .venv\Scripts\activate
42
+ # macOS/Linux:
43
+ source .venv/bin/activate
44
+
45
+ python -m pip install -U pip
46
+ python -m pip install -e .
47
+ ```
48
+
49
+ To include developer tools (recommended for contributors):
50
+
51
+ ```bash
52
+ python -m pip install -e ".[dev]"
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Quick start
58
+
59
+ From inside any repo you want to scan:
60
+
61
+ ```bash
62
+ codedoctor scan .
63
+ ```
64
+
65
+ CodeDoctor prints a report to the terminal and also writes a report file under:
66
+
67
+ - `.codedoctor/report-latest.txt`
68
+ - `.codedoctor/report-prev.txt` (previous run)
69
+ - `.codedoctor/report-YYYYMMDD-HHMMSS.txt` (timestamped history)
70
+
71
+ ---
72
+
73
+ ## Example output (TL;DR)
74
+
75
+ ```text
76
+ CodeDoctor Report TL;DR
77
+ ----------------------
78
+ Overall: WARN
79
+ Checks: 6 passed / 1 warned / 0 failed / 7 total
80
+
81
+ Warnings:
82
+ - pytest (tests)
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Commands
88
+
89
+ ### Scan a repo
90
+
91
+ ```bash
92
+ codedoctor scan .
93
+ ```
94
+
95
+ ### Apply safe auto-fixes (where supported)
96
+
97
+ ```bash
98
+ codedoctor scan . --fix
99
+ ```
100
+
101
+ This may run tools like:
102
+
103
+ - `ruff check . --fix`
104
+ - `black .`
105
+
106
+ ### Skip running tests
107
+
108
+ ```bash
109
+ codedoctor scan . --skip-tests
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Exit codes (for CI)
115
+
116
+ CodeDoctor uses simple exit codes so it can be used in CI:
117
+
118
+ - **0** — all checks PASS
119
+ - **1** — at least one WARN, and no FAIL
120
+ - **2** — one or more FAIL
121
+
122
+ ---
123
+
124
+ ## Notes (Windows + pytest warnings)
125
+
126
+ On Windows, `pytest` may sometimes print messages like:
127
+
128
+ - `Exception ignored in atexit callback`
129
+ - `PermissionError: [WinError 5] Access is denied`
130
+
131
+ Even if tests pass, CodeDoctor may classify the result as **WARN** so the run is
132
+ not marked as perfectly clean.
133
+
134
+ ---
135
+
136
+ ## Contributing
137
+
138
+ PRs welcome. A typical workflow:
139
+
140
+ ```bash
141
+ python -m pip install -e ".[dev]"
142
+ pre-commit run --all-files
143
+ pytest -q
144
+ ```
145
+
146
+ ---
147
+
148
+ ## License
149
+
150
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "codedoctor"
7
+ version = "0.1.0"
8
+ description = "Beginner-friendly codebase doctor: lint, format, type-check, security and tests."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [{ name = "Patrick Faint", email = "pattymayo3@icloud.com" }]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0.0",
18
+ "ruff>=0.8.0",
19
+ "black>=24.0.0",
20
+ "mypy>=1.10.0",
21
+ "bandit>=1.7.0",
22
+ "pre-commit>=3.7.0",
23
+ ]
24
+
25
+ [project.scripts]
26
+ codedoctor = "codedoctor.cli:main"
27
+
28
+ [tool.ruff]
29
+ line-length = 88
30
+ target-version = "py312"
31
+
32
+ [tool.black]
33
+ line-length = 88
34
+ target-version = ["py312"]
35
+
36
+ [tool.mypy]
37
+ python_version = "3.12"
38
+ warn_return_any = true
39
+ warn_unused_configs = true
File without changes
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from codedoctor.runner import scan_repo
7
+ from codedoctor.storage import get_report_paths, rotate_latest_to_previous
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ prog="codedoctor",
13
+ description="Beginner-friendly checks for Python repositories.",
14
+ )
15
+ subparsers = parser.add_subparsers(dest="command", required=True)
16
+
17
+ scan = subparsers.add_parser("scan", help="Scan a repository.")
18
+ scan.add_argument("path", nargs="?", default=".", help="Repo path (default: .)")
19
+ scan.add_argument(
20
+ "--fix", action="store_true", help="Apple safe auto_fixes (ruff --fix, black)."
21
+ )
22
+ scan.add_argument("--skip-tests", action="store_true", help="Skip running pytest.")
23
+ scan.add_argument(
24
+ "--report-dir",
25
+ default=".codedoctor",
26
+ help="Directory (relative to repo) to store reports.",
27
+ )
28
+
29
+ return parser
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ args = build_parser().parse_args(argv)
34
+ repo_path = Path(args.path).resolve()
35
+
36
+ if args.command == "scan":
37
+ report = scan_repo(
38
+ repo_path=repo_path,
39
+ apply_fixes=bool(args.fix),
40
+ skip_tests=bool(args.skip_tests),
41
+ )
42
+
43
+ report_root = repo_path / args.report_dir
44
+ paths = get_report_paths(repo_path=repo_path)
45
+
46
+ paths = paths.__class__(
47
+ directory=report_root,
48
+ latest=report_root / "report-latest.txt",
49
+ previous=report_root / "report-prev.txt",
50
+ timestamped=report_root / paths.timestamped.name,
51
+ )
52
+
53
+ report_root.mkdir(parents=True, exist_ok=True)
54
+ rotate_latest_to_previous(latest=paths.latest, previous=paths.previous)
55
+
56
+ text = report.to_full_text()
57
+ paths.latest.write_text(text, encoding="utf-8")
58
+ paths.timestamped.write_text(text, encoding="utf-8")
59
+
60
+ print(text)
61
+ print(f"\nWrote: {paths.latest}")
62
+ print(f"Wrote: {paths.timestamped}")
63
+ if paths.previous.exists():
64
+ print(f"Previous: {paths.previous}")
65
+
66
+ return report.exit_code
67
+
68
+ return 1
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class CheckStatus(str, Enum):
8
+ PASS = "PASS" # nosec B105
9
+ WARN = "WARN"
10
+ FAIL = "FAIL"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CheckResult:
15
+ name: str
16
+ command: list[str]
17
+ returncode: int
18
+ output: str
19
+ status: CheckStatus
20
+
21
+ @property
22
+ def ok(self) -> bool:
23
+ return self.status in {CheckStatus.PASS, CheckStatus.WARN}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ScanReport:
28
+ repo: str
29
+ results: list[CheckResult]
30
+
31
+ @property
32
+ def ok(self) -> bool:
33
+ return all(r.ok for r in self.results)
34
+
35
+ @property
36
+ def has_failures(self) -> bool:
37
+ return any(r.status == CheckStatus.FAIL for r in self.results)
38
+
39
+ @property
40
+ def has_warnings(self) -> bool:
41
+ return any(r.status == CheckStatus.WARN for r in self.results)
42
+
43
+ @property
44
+ def overall_status(self) -> CheckStatus:
45
+ if self.has_failures:
46
+ return CheckStatus.FAIL
47
+ if self.has_warnings:
48
+ return CheckStatus.WARN
49
+ return CheckStatus.PASS
50
+
51
+ @property
52
+ def exit_code(self) -> int:
53
+ if self.has_failures:
54
+ return 2
55
+ if self.has_warnings:
56
+ return 1
57
+ return 0
58
+
59
+ def to_tldr(self) -> str:
60
+ total = len(self.results)
61
+ passed = sum(1 for r in self.results if r.status == CheckStatus.PASS)
62
+ warned = sum(1 for r in self.results if r.status == CheckStatus.WARN)
63
+ failed = sum(1 for r in self.results if r.status == CheckStatus.FAIL)
64
+
65
+ lines: list[str] = []
66
+ lines.append("CodeDoctor Report TL;DR")
67
+ lines.append("----------------------")
68
+ lines.append(f"Overall: {self.overall_status.value}")
69
+ lines.append(
70
+ f"Checks: {passed} passed / {warned} warned / {failed} failed / "
71
+ f"{total} total"
72
+ )
73
+ lines.append("")
74
+
75
+ if failed:
76
+ lines.append("Failures:")
77
+ for r in self.results:
78
+ if r.status == CheckStatus.FAIL:
79
+ lines.append(f" - {r.name}")
80
+ lines.append("")
81
+
82
+ if warned:
83
+ lines.append("Warnings:")
84
+ for r in self.results:
85
+ if r.status == CheckStatus.WARN:
86
+ lines.append(f" - {r.name}")
87
+ lines.append("")
88
+
89
+ return "\n".join(lines)
90
+
91
+ def to_full_text(self) -> str:
92
+ lines: list[str] = []
93
+ lines.append(self.to_tldr())
94
+ lines.append(f"Repository: {self.repo}")
95
+ lines.append("")
96
+ lines.append("Details")
97
+ lines.append("-------")
98
+ lines.append("")
99
+
100
+ for r in self.results:
101
+ lines.append(f"== {r.name} : {r.status.value} ==")
102
+ if r.command:
103
+ lines.append(f"$ {' '.join(r.command)}")
104
+ lines.append(r.output if r.output else "(no output)")
105
+ lines.append("")
106
+
107
+ lines.append("Next steps:")
108
+ lines.append(" - Re-run with safe auto-fixes: codedoctor scan . --fix")
109
+ lines.append("")
110
+ return "\n".join(lines)
111
+
112
+ def to_human_text(self) -> str:
113
+ return self.to_full_text()
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess # nosec B404
5
+ from pathlib import Path
6
+
7
+ from codedoctor.report import CheckResult, CheckStatus, ScanReport
8
+
9
+
10
+ def tool_exists(tool: str) -> bool:
11
+ return shutil.which(tool) is not None
12
+
13
+
14
+ def classify_status(name: str, returncode: int, output: str) -> CheckStatus:
15
+ if returncode != 0:
16
+ return CheckStatus.FAIL
17
+
18
+ # Pytest can return 0 but still print nasty shutdown exceptions on Windows.
19
+ warning_signatures = [
20
+ "Exception ignored in atexit callback",
21
+ "PermissionError: [WinError 5]",
22
+ "Traceback (most recent call last):",
23
+ ]
24
+ if name.startswith("pytest") and any(s in output for s in warning_signatures):
25
+ return CheckStatus.WARN
26
+
27
+ return CheckStatus.PASS
28
+
29
+
30
+ def run_command(display_name: str, cmd: list[str], cwd: Path) -> CheckResult:
31
+ proc = subprocess.run( # nosec B603
32
+ cmd,
33
+ cwd=str(cwd),
34
+ capture_output=True,
35
+ text=True,
36
+ )
37
+ output = ((proc.stdout or "") + (proc.stderr or "")).strip()
38
+ status = classify_status(
39
+ name=display_name, returncode=proc.returncode, output=output
40
+ )
41
+
42
+ return CheckResult(
43
+ name=display_name,
44
+ command=cmd,
45
+ returncode=proc.returncode,
46
+ output=output,
47
+ status=status,
48
+ )
49
+
50
+
51
+ def build_checks(apply_fixes: bool, skip_tests: bool) -> list[tuple[str, list[str]]]:
52
+ checks: list[tuple[str, list[str]]] = []
53
+
54
+ # Ruff
55
+ if tool_exists("ruff"):
56
+ if apply_fixes:
57
+ checks.append(("ruff (auto-fix)", ["ruff", "check", ".", "--fix"]))
58
+ checks.append(("ruff (lint)", ["ruff", "check", "."]))
59
+ else:
60
+ checks.append(("ruff (missing)", []))
61
+
62
+ # Black
63
+ if tool_exists("black"):
64
+ if apply_fixes:
65
+ checks.append(("black (format)", ["black", "."]))
66
+ checks.append(("black (check)", ["black", ".", "--check"]))
67
+ else:
68
+ checks.append(("black (missing)", []))
69
+
70
+ # MyPy
71
+ if tool_exists("mypy"):
72
+ checks.append(("mypy (types)", ["mypy", "src"]))
73
+
74
+ # Bandit
75
+ if tool_exists("bandit"):
76
+ checks.append(("bandit (security)", ["bandit", "-r", "src"]))
77
+
78
+ # Pytest
79
+ if not skip_tests and tool_exists("pytest"):
80
+ checks.append(("pytest (tests)", ["pytest", "-q"]))
81
+
82
+ return checks
83
+
84
+
85
+ def scan_repo(repo_path: Path, apply_fixes: bool, skip_tests: bool) -> ScanReport:
86
+ results: list[CheckResult] = []
87
+
88
+ for name, cmd in build_checks(apply_fixes=apply_fixes, skip_tests=skip_tests):
89
+ if not cmd:
90
+ tool = name.split(" ", 1)[0]
91
+ results.append(
92
+ CheckResult(
93
+ name=name,
94
+ command=[],
95
+ returncode=127,
96
+ output=(
97
+ f"{tool} is not installed or not on PATH.\n"
98
+ f"Install it with: python -m pip install {tool}"
99
+ ),
100
+ status=CheckStatus.FAIL,
101
+ )
102
+ )
103
+ continue
104
+
105
+ results.append(run_command(display_name=name, cmd=cmd, cwd=repo_path))
106
+
107
+ return ScanReport(repo=str(repo_path), results=results)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ReportPaths:
10
+ directory: Path
11
+ latest: Path
12
+ previous: Path
13
+ timestamped: Path
14
+
15
+
16
+ def get_report_paths(repo_path: Path) -> ReportPaths:
17
+ report_dir = repo_path / ".codedoctor"
18
+ latest = report_dir / "report-latest.txt"
19
+ previous = report_dir / "report-prev.txt"
20
+
21
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
22
+ timestamped = report_dir / f"report-{ts}.txt"
23
+
24
+ return ReportPaths(
25
+ directory=report_dir,
26
+ latest=latest,
27
+ previous=previous,
28
+ timestamped=timestamped,
29
+ )
30
+
31
+
32
+ def rotate_latest_to_previous(latest: Path, previous: Path) -> None:
33
+ if latest.exists():
34
+ previous.parent.mkdir(parents=True, exist_ok=True)
35
+ if previous.exists():
36
+ previous.unlink()
37
+ latest.replace(previous)
@@ -0,0 +1,7 @@
1
+ from codedoctor.runner import scan_repo
2
+
3
+
4
+ def test_scan_repo_smoke(tmp_path) -> None:
5
+ repo = tmp_path
6
+ report = scan_repo(repo_path=repo, apply_fixes=False, skip_tests=True)
7
+ assert report.results