devdoctor-cli 0.1.0__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.
- devdoctor/__init__.py +40 -0
- devdoctor/__main__.py +7 -0
- devdoctor/api.py +96 -0
- devdoctor/checks/__init__.py +38 -0
- devdoctor/checks/base.py +69 -0
- devdoctor/checks/dependencies.py +125 -0
- devdoctor/checks/django.py +128 -0
- devdoctor/checks/docker.py +124 -0
- devdoctor/checks/environment.py +146 -0
- devdoctor/checks/fastapi.py +96 -0
- devdoctor/checks/filesystem.py +72 -0
- devdoctor/checks/git.py +151 -0
- devdoctor/checks/imports.py +47 -0
- devdoctor/checks/pip.py +67 -0
- devdoctor/checks/ports.py +90 -0
- devdoctor/checks/postgres.py +65 -0
- devdoctor/checks/python.py +105 -0
- devdoctor/checks/redis.py +70 -0
- devdoctor/checks/virtualenv.py +79 -0
- devdoctor/cli.py +319 -0
- devdoctor/fixer.py +160 -0
- devdoctor/models.py +257 -0
- devdoctor/plugin.py +89 -0
- devdoctor/project.py +223 -0
- devdoctor/reporter.py +193 -0
- devdoctor/scanner.py +98 -0
- devdoctor/utils/__init__.py +1 -0
- devdoctor/utils/ast_imports.py +205 -0
- devdoctor/utils/env_parser.py +57 -0
- devdoctor/utils/platform_info.py +70 -0
- devdoctor/utils/process_utils.py +167 -0
- devdoctor_cli-0.1.0.dist-info/METADATA +382 -0
- devdoctor_cli-0.1.0.dist-info/RECORD +37 -0
- devdoctor_cli-0.1.0.dist-info/WHEEL +5 -0
- devdoctor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devdoctor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devdoctor_cli-0.1.0.dist-info/top_level.txt +1 -0
devdoctor/project.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Project detector: inspects files and config to determine project type and tech stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
from devdoctor.models import ProjectInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_requirements_txt(file_path: Path) -> Dict[str, str]:
|
|
14
|
+
"""Extract package names and version specifiers from a requirements.txt file."""
|
|
15
|
+
deps: Dict[str, str] = {}
|
|
16
|
+
if not file_path.is_file():
|
|
17
|
+
return deps
|
|
18
|
+
try:
|
|
19
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
20
|
+
for line in content.splitlines():
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
23
|
+
continue
|
|
24
|
+
# Match package name and optional version specifier
|
|
25
|
+
match = re.match(r"^([A-Za-z0-9_.\-]+)\s*([<>=!~].*)?$", line)
|
|
26
|
+
if match:
|
|
27
|
+
pkg = match.group(1).lower().replace("_", "-")
|
|
28
|
+
spec = match.group(2).strip() if match.group(2) else "*"
|
|
29
|
+
deps[pkg] = spec
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return deps
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_pyproject_deps(file_path: Path) -> Dict[str, str]:
|
|
36
|
+
"""Parse dependencies from pyproject.toml."""
|
|
37
|
+
deps: Dict[str, str] = {}
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
return deps
|
|
40
|
+
try:
|
|
41
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
42
|
+
# Match standard PEP 621 dependencies list: dependencies = [ ... ]
|
|
43
|
+
in_deps = False
|
|
44
|
+
for line in content.splitlines():
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if re.match(r"^dependencies\s*=\s*\[", line):
|
|
47
|
+
in_deps = True
|
|
48
|
+
continue
|
|
49
|
+
if in_deps:
|
|
50
|
+
if line.startswith("]"):
|
|
51
|
+
in_deps = False
|
|
52
|
+
continue
|
|
53
|
+
# Match quoted string like "fastapi>=0.100.0"
|
|
54
|
+
match = re.search(r'["\']([A-Za-z0-9_.\-]+)([^"\']*)["\']', line)
|
|
55
|
+
if match:
|
|
56
|
+
pkg = match.group(1).lower().replace("_", "-")
|
|
57
|
+
spec = match.group(2).strip() or "*"
|
|
58
|
+
deps[pkg] = spec
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return deps
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect_project(project_path: Path) -> ProjectInfo:
|
|
65
|
+
"""Analyze the project directory and build a deterministic ProjectInfo profile."""
|
|
66
|
+
root = project_path.resolve()
|
|
67
|
+
name = root.name
|
|
68
|
+
|
|
69
|
+
config_files: List[str] = []
|
|
70
|
+
technologies: List[str] = ["Python"]
|
|
71
|
+
frameworks: List[str] = []
|
|
72
|
+
project_type: Optional[str] = None
|
|
73
|
+
dependencies: Dict[str, str] = {}
|
|
74
|
+
|
|
75
|
+
# Check key files
|
|
76
|
+
known_files = [
|
|
77
|
+
"pyproject.toml",
|
|
78
|
+
"requirements.txt",
|
|
79
|
+
"setup.py",
|
|
80
|
+
"manage.py",
|
|
81
|
+
"Dockerfile",
|
|
82
|
+
"docker-compose.yml",
|
|
83
|
+
"docker-compose.yaml",
|
|
84
|
+
"compose.yaml",
|
|
85
|
+
"compose.yml",
|
|
86
|
+
".env",
|
|
87
|
+
".env.example",
|
|
88
|
+
".git",
|
|
89
|
+
"alembic.ini",
|
|
90
|
+
"package.json",
|
|
91
|
+
"Pipfile",
|
|
92
|
+
"poetry.lock",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
for fname in known_files:
|
|
96
|
+
if (root / fname).exists():
|
|
97
|
+
config_files.append(fname)
|
|
98
|
+
|
|
99
|
+
# Collect dependencies
|
|
100
|
+
if (root / "requirements.txt").exists():
|
|
101
|
+
dependencies.update(parse_requirements_txt(root / "requirements.txt"))
|
|
102
|
+
if (root / "pyproject.toml").exists():
|
|
103
|
+
dependencies.update(parse_pyproject_deps(root / "pyproject.toml"))
|
|
104
|
+
|
|
105
|
+
dep_keys = set(dependencies.keys())
|
|
106
|
+
|
|
107
|
+
# Detect Django
|
|
108
|
+
if (root / "manage.py").exists() or "django" in dep_keys:
|
|
109
|
+
project_type = "Django"
|
|
110
|
+
frameworks.append("Django")
|
|
111
|
+
if "Django" not in technologies:
|
|
112
|
+
technologies.append("Django")
|
|
113
|
+
|
|
114
|
+
# Detect FastAPI
|
|
115
|
+
elif "fastapi" in dep_keys:
|
|
116
|
+
project_type = "FastAPI"
|
|
117
|
+
frameworks.append("FastAPI")
|
|
118
|
+
if "FastAPI" not in technologies:
|
|
119
|
+
technologies.append("FastAPI")
|
|
120
|
+
|
|
121
|
+
# Detect Flask
|
|
122
|
+
elif "flask" in dep_keys:
|
|
123
|
+
project_type = "Flask"
|
|
124
|
+
frameworks.append("Flask")
|
|
125
|
+
if "Flask" not in technologies:
|
|
126
|
+
technologies.append("Flask")
|
|
127
|
+
|
|
128
|
+
# Check AST or code if not found via dependencies
|
|
129
|
+
if not project_type:
|
|
130
|
+
for py_file in ["main.py", "app.py", "api.py", "server.py"]:
|
|
131
|
+
candidate = root / py_file
|
|
132
|
+
if candidate.is_file():
|
|
133
|
+
try:
|
|
134
|
+
text = candidate.read_text(encoding="utf-8", errors="replace")
|
|
135
|
+
if "FastAPI(" in text or "from fastapi import" in text:
|
|
136
|
+
project_type = "FastAPI"
|
|
137
|
+
frameworks.append("FastAPI")
|
|
138
|
+
if "FastAPI" not in technologies:
|
|
139
|
+
technologies.append("FastAPI")
|
|
140
|
+
break
|
|
141
|
+
elif "Flask(" in text or "from flask import" in text:
|
|
142
|
+
project_type = "Flask"
|
|
143
|
+
frameworks.append("Flask")
|
|
144
|
+
if "Flask" not in technologies:
|
|
145
|
+
technologies.append("Flask")
|
|
146
|
+
break
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
if not project_type:
|
|
151
|
+
project_type = "Generic Python"
|
|
152
|
+
|
|
153
|
+
# Detect Docker
|
|
154
|
+
has_docker = (
|
|
155
|
+
(root / "Dockerfile").exists()
|
|
156
|
+
or (root / "docker-compose.yml").exists()
|
|
157
|
+
or (root / "docker-compose.yaml").exists()
|
|
158
|
+
or (root / "compose.yaml").exists()
|
|
159
|
+
or (root / "compose.yml").exists()
|
|
160
|
+
)
|
|
161
|
+
if has_docker:
|
|
162
|
+
technologies.append("Docker")
|
|
163
|
+
|
|
164
|
+
# Detect Compose contents for database and cache services
|
|
165
|
+
compose_content = ""
|
|
166
|
+
for cfile in ["docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml"]:
|
|
167
|
+
target = root / cfile
|
|
168
|
+
if target.is_file():
|
|
169
|
+
try:
|
|
170
|
+
compose_content += "\n" + target.read_text(encoding="utf-8", errors="replace").lower()
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
# Read .env if present
|
|
175
|
+
env_content = ""
|
|
176
|
+
if (root / ".env").is_file():
|
|
177
|
+
try:
|
|
178
|
+
env_content = (root / ".env").read_text(encoding="utf-8", errors="replace").lower()
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
# Detect PostgreSQL
|
|
183
|
+
if (
|
|
184
|
+
"postgres" in compose_content
|
|
185
|
+
or "psql" in compose_content
|
|
186
|
+
or "psycopg" in dep_keys
|
|
187
|
+
or "psycopg2" in dep_keys
|
|
188
|
+
or "psycopg2-binary" in dep_keys
|
|
189
|
+
or "asyncpg" in dep_keys
|
|
190
|
+
or "postgresql://" in env_content
|
|
191
|
+
or "postgres://" in env_content
|
|
192
|
+
):
|
|
193
|
+
technologies.append("PostgreSQL")
|
|
194
|
+
|
|
195
|
+
# Detect Redis
|
|
196
|
+
if (
|
|
197
|
+
"redis" in compose_content
|
|
198
|
+
or "redis" in dep_keys
|
|
199
|
+
or "redis://" in env_content
|
|
200
|
+
):
|
|
201
|
+
technologies.append("Redis")
|
|
202
|
+
|
|
203
|
+
# Detect Celery
|
|
204
|
+
if "celery" in dep_keys or "celery" in compose_content:
|
|
205
|
+
technologies.append("Celery")
|
|
206
|
+
|
|
207
|
+
# Detect Alembic
|
|
208
|
+
if (root / "alembic.ini").exists() or "alembic" in dep_keys:
|
|
209
|
+
technologies.append("Alembic")
|
|
210
|
+
|
|
211
|
+
# Detect Node.js
|
|
212
|
+
if (root / "package.json").exists():
|
|
213
|
+
technologies.append("Node.js")
|
|
214
|
+
|
|
215
|
+
return ProjectInfo(
|
|
216
|
+
root=root,
|
|
217
|
+
name=name,
|
|
218
|
+
project_type=project_type,
|
|
219
|
+
technologies=technologies,
|
|
220
|
+
frameworks=frameworks,
|
|
221
|
+
config_files=config_files,
|
|
222
|
+
dependencies=dependencies,
|
|
223
|
+
)
|
devdoctor/reporter.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""DevDoctor output reporter: renders deterministic CLI views, verbose diagnostics, and JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
# Ensure UTF-8 output on Windows terminals
|
|
12
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
13
|
+
try:
|
|
14
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
19
|
+
try:
|
|
20
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
# Configure Console with legacy_windows=False to prevent charmap encoding errors
|
|
25
|
+
console = Console(legacy_windows=False)
|
|
26
|
+
|
|
27
|
+
# Unicode characters with safe ASCII fallbacks if required
|
|
28
|
+
def can_encode(char: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
char.encode(sys.stdout.encoding or "utf-8")
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
SYM_OK = "✓" if can_encode("✓") else "[OK]"
|
|
36
|
+
SYM_WARN = "⚠" if can_encode("⚠") else "[!]"
|
|
37
|
+
SYM_ERR = "❌" if can_encode("❌") else "[X]"
|
|
38
|
+
SYM_DIV = "────────────────────────────" if can_encode("─") else "----------------------------"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Reporter:
|
|
42
|
+
"""Renders DiagnosticReports into terminal, verbose, JSON, or Markdown formats."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, console_instance: Optional[Console] = None):
|
|
45
|
+
self.console = console_instance or console
|
|
46
|
+
|
|
47
|
+
def render_terminal(self, report: DiagnosticReport, verbose: bool = False) -> None:
|
|
48
|
+
"""Render the default or verbose DevDoctor scan output to the terminal."""
|
|
49
|
+
if verbose:
|
|
50
|
+
self._render_verbose(report)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Header
|
|
54
|
+
self.console.print("[bold cyan]DevDoctor 0.1.0[/bold cyan]")
|
|
55
|
+
if report.project_info and report.project_info.summary_stack:
|
|
56
|
+
self.console.print(f"[dim]Project: {report.project_info.summary_stack}[/dim]")
|
|
57
|
+
self.console.print(f"{SYM_DIV}\n")
|
|
58
|
+
|
|
59
|
+
# 1. Passed checks (compact green checkmarks)
|
|
60
|
+
if report.passed:
|
|
61
|
+
for diag in report.passed:
|
|
62
|
+
self.console.print(f"[green]{SYM_OK}[/green] {diag.title}")
|
|
63
|
+
self.console.print()
|
|
64
|
+
|
|
65
|
+
# 2. Problems (Warnings and Errors)
|
|
66
|
+
if report.problems:
|
|
67
|
+
for diag in report.problems:
|
|
68
|
+
sym = f"[red]{SYM_ERR}[/red]" if diag.is_error else f"[yellow]{SYM_WARN}[/yellow]"
|
|
69
|
+
self.console.print(f"{sym} [bold]{diag.title}[/bold]")
|
|
70
|
+
if diag.message:
|
|
71
|
+
self.console.print(f" {diag.message}\n")
|
|
72
|
+
|
|
73
|
+
if diag.evidence:
|
|
74
|
+
self.console.print(f" {diag.evidence}\n")
|
|
75
|
+
|
|
76
|
+
if diag.fix:
|
|
77
|
+
self.console.print(" Fix:")
|
|
78
|
+
for line in diag.fix.splitlines():
|
|
79
|
+
self.console.print(f" {line}")
|
|
80
|
+
self.console.print()
|
|
81
|
+
|
|
82
|
+
# 3. Score
|
|
83
|
+
score = report.score
|
|
84
|
+
if score >= 90:
|
|
85
|
+
score_color = "green"
|
|
86
|
+
elif score >= 70:
|
|
87
|
+
score_color = "yellow"
|
|
88
|
+
else:
|
|
89
|
+
score_color = "red"
|
|
90
|
+
|
|
91
|
+
self.console.print(f"[bold {score_color}]Score: {score}/100[/bold {score_color}]\n")
|
|
92
|
+
|
|
93
|
+
# 4. Summary
|
|
94
|
+
total_p = report.total_problems
|
|
95
|
+
if total_p == 0:
|
|
96
|
+
self.console.print(f"[bold green]{SYM_OK} All checks passed! Machine and project are healthy.[/bold green]")
|
|
97
|
+
else:
|
|
98
|
+
problem_word = "problem" if total_p == 1 else "problems"
|
|
99
|
+
color = "red" if report.errors else "yellow"
|
|
100
|
+
self.console.print(f"[{color}]{total_p} {problem_word} found.[/{color}]")
|
|
101
|
+
|
|
102
|
+
def _render_verbose(self, report: DiagnosticReport) -> None:
|
|
103
|
+
"""Render full diagnostic detail with WHY, EVIDENCE, SAFE FIX, COMMAND, and Confidence."""
|
|
104
|
+
self.console.print("[bold cyan]DevDoctor 0.1.0 (Detailed Diagnostic Report)[/bold cyan]")
|
|
105
|
+
self.console.print(f"{SYM_DIV * 2}\n")
|
|
106
|
+
|
|
107
|
+
if report.passed:
|
|
108
|
+
self.console.print("[bold green]PASSED CHECKS:[/bold green]")
|
|
109
|
+
for diag in report.passed:
|
|
110
|
+
self.console.print(f" [green]{SYM_OK}[/green] {diag.title}: {diag.message}")
|
|
111
|
+
self.console.print()
|
|
112
|
+
|
|
113
|
+
if report.problems:
|
|
114
|
+
self.console.print("[bold red]IDENTIFIED PROBLEMS & DIAGNOSTICS:[/bold red]\n")
|
|
115
|
+
for diag in report.problems:
|
|
116
|
+
sym = SYM_ERR if diag.is_error else SYM_WARN
|
|
117
|
+
self.console.print(f"{sym} [bold underline]{diag.title}[/bold underline]\n")
|
|
118
|
+
|
|
119
|
+
if diag.message:
|
|
120
|
+
self.console.print(f"[bold]PROBLEM:[/bold]\n{diag.message}\n")
|
|
121
|
+
|
|
122
|
+
if diag.why:
|
|
123
|
+
self.console.print(f"[bold]WHY:[/bold]\n{diag.why}\n")
|
|
124
|
+
|
|
125
|
+
if diag.evidence:
|
|
126
|
+
self.console.print(f"[bold]EVIDENCE:[/bold]\n{diag.evidence}\n")
|
|
127
|
+
|
|
128
|
+
if diag.fix:
|
|
129
|
+
self.console.print(f"[bold]SAFE FIX:[/bold]\n{diag.fix}\n")
|
|
130
|
+
|
|
131
|
+
if diag.command:
|
|
132
|
+
self.console.print(f"[bold]COMMAND:[/bold]\n{diag.command}\n")
|
|
133
|
+
|
|
134
|
+
conf = diag.confidence.value if hasattr(diag.confidence, "value") else str(diag.confidence)
|
|
135
|
+
self.console.print(f"Confidence: [cyan]{conf}[/cyan]\n")
|
|
136
|
+
self.console.print(f"{SYM_DIV * 2}\n")
|
|
137
|
+
|
|
138
|
+
# Score & Summary
|
|
139
|
+
score = report.score
|
|
140
|
+
score_color = "green" if score >= 90 else ("yellow" if score >= 70 else "red")
|
|
141
|
+
self.console.print(f"[bold {score_color}]Health Score: {score}/100[/bold {score_color}] ({report.total_problems} problems found)")
|
|
142
|
+
|
|
143
|
+
def render_json(self, report: DiagnosticReport) -> str:
|
|
144
|
+
"""Return JSON representation of the diagnostic report."""
|
|
145
|
+
return json.dumps(report.to_dict(), indent=2)
|
|
146
|
+
|
|
147
|
+
def render_markdown(self, report: DiagnosticReport) -> str:
|
|
148
|
+
"""Render a GitHub-flavored Markdown diagnostic report."""
|
|
149
|
+
lines = [
|
|
150
|
+
f"# DevDoctor Diagnostic Report",
|
|
151
|
+
f"",
|
|
152
|
+
f"- **Target**: `{report.project_path}`",
|
|
153
|
+
f"- **Timestamp**: `{report.timestamp.isoformat()}`",
|
|
154
|
+
f"- **Health Score**: **{report.score}/100**",
|
|
155
|
+
f"- **Problems Found**: {report.total_problems} ({len(report.errors)} errors, {len(report.warnings)} warnings)",
|
|
156
|
+
f"- **Project Profile**: {report.project_info.summary_stack}",
|
|
157
|
+
f"",
|
|
158
|
+
f"---",
|
|
159
|
+
f"",
|
|
160
|
+
f"## Identified Problems",
|
|
161
|
+
f"",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
if not report.problems:
|
|
165
|
+
lines.append("All checks passed successfully! Machine and project are in a healthy state.")
|
|
166
|
+
else:
|
|
167
|
+
for diag in report.problems:
|
|
168
|
+
icon = "❌" if diag.is_error else "⚠️"
|
|
169
|
+
lines.append(f"### {icon} {diag.title}")
|
|
170
|
+
lines.append(f"")
|
|
171
|
+
lines.append(f"**Problem**: {diag.message}")
|
|
172
|
+
if diag.why:
|
|
173
|
+
lines.append(f"")
|
|
174
|
+
lines.append(f"> **Why**: {diag.why}")
|
|
175
|
+
if diag.evidence:
|
|
176
|
+
lines.append(f"")
|
|
177
|
+
lines.append(f"```\n{diag.evidence}\n```")
|
|
178
|
+
if diag.fix:
|
|
179
|
+
lines.append(f"")
|
|
180
|
+
lines.append(f"**Recommended Fix**:")
|
|
181
|
+
lines.append(f"```bash\n{diag.command or diag.fix}\n```")
|
|
182
|
+
lines.append(f"")
|
|
183
|
+
lines.append(f"*Confidence: {diag.confidence.value}*")
|
|
184
|
+
lines.append(f"")
|
|
185
|
+
lines.append("---")
|
|
186
|
+
lines.append("")
|
|
187
|
+
|
|
188
|
+
lines.append("## Passed Checks")
|
|
189
|
+
lines.append("")
|
|
190
|
+
for diag in report.passed:
|
|
191
|
+
lines.append(f"- ✓ **{diag.title}**: {diag.message}")
|
|
192
|
+
|
|
193
|
+
return "\n".join(lines)
|
devdoctor/scanner.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Scanner engine: coordinates project detection, check execution, and report generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Set
|
|
8
|
+
|
|
9
|
+
from devdoctor.checks import get_registered_checks
|
|
10
|
+
from devdoctor.models import Diagnostic, DiagnosticReport, ScanContext, Severity
|
|
11
|
+
from devdoctor.plugin import discover_all_checks
|
|
12
|
+
from devdoctor.project import detect_project
|
|
13
|
+
from devdoctor.utils.platform_info import get_python_version_str, is_in_virtualenv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Scanner:
|
|
17
|
+
"""Core diagnostic scanner for DevDoctor."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, project_path: Optional[Path] = None, custom_checks_dir: Optional[Path] = None):
|
|
20
|
+
self.project_path = (project_path or Path.cwd()).resolve()
|
|
21
|
+
self.custom_checks_dir = custom_checks_dir
|
|
22
|
+
|
|
23
|
+
def create_context(self) -> ScanContext:
|
|
24
|
+
"""Create and populate the ScanContext for this project."""
|
|
25
|
+
project_info = detect_project(self.project_path)
|
|
26
|
+
return ScanContext(
|
|
27
|
+
project_path=self.project_path,
|
|
28
|
+
is_venv=is_in_virtualenv(),
|
|
29
|
+
python_version=get_python_version_str(),
|
|
30
|
+
project_info=project_info,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def scan(
|
|
34
|
+
self,
|
|
35
|
+
categories: Optional[Set[str]] = None,
|
|
36
|
+
check_names: Optional[Set[str]] = None,
|
|
37
|
+
include_frameworks: bool = True,
|
|
38
|
+
) -> DiagnosticReport:
|
|
39
|
+
"""Run all diagnostic checks against the target project and produce a report."""
|
|
40
|
+
start_time = time.perf_counter()
|
|
41
|
+
|
|
42
|
+
context = self.create_context()
|
|
43
|
+
all_checks = discover_all_checks(self.project_path, self.custom_checks_dir)
|
|
44
|
+
|
|
45
|
+
diagnostics: List[Diagnostic] = []
|
|
46
|
+
|
|
47
|
+
for chk in all_checks:
|
|
48
|
+
# Check name filter
|
|
49
|
+
if check_names and chk.name not in check_names:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Category filter
|
|
53
|
+
if categories and chk.category not in categories:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# Framework checks: only run automatically if the technology is detected in the project
|
|
57
|
+
# or if explicitly requested via check_names / category
|
|
58
|
+
if chk.category == "framework" and not check_names and not categories:
|
|
59
|
+
if not include_frameworks:
|
|
60
|
+
continue
|
|
61
|
+
# If framework not detected in project_info, skip
|
|
62
|
+
if chk.name == "django" and ("Django" not in (context.project_info.frameworks if context.project_info else [])):
|
|
63
|
+
continue
|
|
64
|
+
if chk.name == "fastapi" and ("FastAPI" not in (context.project_info.frameworks if context.project_info else [])):
|
|
65
|
+
continue
|
|
66
|
+
if chk.name == "docker" and ("Docker" not in (context.project_info.technologies if context.project_info else [])):
|
|
67
|
+
continue
|
|
68
|
+
if chk.name == "postgres" and ("PostgreSQL" not in (context.project_info.technologies if context.project_info else [])):
|
|
69
|
+
continue
|
|
70
|
+
if chk.name == "redis" and ("Redis" not in (context.project_info.technologies if context.project_info else [])):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
result = chk.run(context)
|
|
75
|
+
if result is None:
|
|
76
|
+
continue
|
|
77
|
+
elif isinstance(result, list):
|
|
78
|
+
diagnostics.extend(result)
|
|
79
|
+
elif isinstance(result, Diagnostic):
|
|
80
|
+
diagnostics.append(result)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# Fallback diagnostic if check throws an unexpected error
|
|
83
|
+
diagnostics.append(Diagnostic(
|
|
84
|
+
title=f"Check '{chk.name}' failed to run",
|
|
85
|
+
severity=Severity.WARNING,
|
|
86
|
+
message=f"Internal check error: {e}",
|
|
87
|
+
why=f"An exception occurred inside the check runner for {chk.name}.",
|
|
88
|
+
check_id=f"error_{chk.name}",
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
92
|
+
|
|
93
|
+
return DiagnosticReport(
|
|
94
|
+
project_path=self.project_path,
|
|
95
|
+
project_info=context.project_info,
|
|
96
|
+
diagnostics=diagnostics,
|
|
97
|
+
duration_ms=duration_ms,
|
|
98
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility helpers for DevDoctor."""
|