spawnio 1.0.5__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.
- spawn/__init__.py +6 -0
- spawn/cli/__init__.py +0 -0
- spawn/cli/app.py +218 -0
- spawn/cli/noninteractive.py +204 -0
- spawn/cli/prompts.py +267 -0
- spawn/core/__init__.py +0 -0
- spawn/core/exceptions.py +7 -0
- spawn/core/models.py +21 -0
- spawn/core/registry.py +152 -0
- spawn/generators/__init__.py +0 -0
- spawn/generators/custom_structure.py +508 -0
- spawn/generators/project_generator.py +115 -0
- spawn/github/__init__.py +0 -0
- spawn/github/exceptions.py +5 -0
- spawn/github/publisher.py +69 -0
- spawn/github/validators.py +23 -0
- spawn/templates/__init__.py +0 -0
- spawn/templates/agent/__init__.py +182 -0
- spawn/templates/agent/content.py +374 -0
- spawn/templates/automation/__init__.py +100 -0
- spawn/templates/automation/content.py +181 -0
- spawn/templates/backend_api/__init__.py +243 -0
- spawn/templates/backend_api/content.py +509 -0
- spawn/templates/base.py +58 -0
- spawn/templates/chatbot/__init__.py +243 -0
- spawn/templates/chatbot/content.py +668 -0
- spawn/templates/cli_application/__init__.py +211 -0
- spawn/templates/cli_application/content.py +512 -0
- spawn/templates/data_project/__init__.py +125 -0
- spawn/templates/data_project/analysis/__init__.py +74 -0
- spawn/templates/data_project/analysis/content.py +190 -0
- spawn/templates/data_project/dashboard/__init__.py +61 -0
- spawn/templates/data_project/dashboard/content.py +135 -0
- spawn/templates/data_project/etl/__init__.py +66 -0
- spawn/templates/data_project/etl/content.py +239 -0
- spawn/templates/data_project/ml/__init__.py +62 -0
- spawn/templates/data_project/ml/content.py +239 -0
- spawn/templates/mcp_server/__init__.py +83 -0
- spawn/templates/mcp_server/content.py +161 -0
- spawn/templates/rag/__init__.py +124 -0
- spawn/templates/rag/content.py +463 -0
- spawn/templates/shared_content.py +89 -0
- spawn/utils/__init__.py +0 -0
- spawn/utils/banner.py +41 -0
- spawn/utils/console.py +3 -0
- spawn/utils/doctor.py +594 -0
- spawn/utils/git.py +132 -0
- spawn/utils/success.py +47 -0
- spawn/utils/theme.py +25 -0
- spawn/utils/uv.py +61 -0
- spawn/utils/validators.py +15 -0
- spawnio-1.0.5.dist-info/METADATA +798 -0
- spawnio-1.0.5.dist-info/RECORD +56 -0
- spawnio-1.0.5.dist-info/WHEEL +4 -0
- spawnio-1.0.5.dist-info/entry_points.txt +2 -0
- spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
spawn/utils/doctor.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Project health checker for Spawn CLI.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive project health analysis,
|
|
4
|
+
checking for best practices and common project standards.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, List
|
|
10
|
+
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from spawn.utils.console import console
|
|
15
|
+
from spawn.utils.theme import BORDER_COLOR
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Tier sets for recommendations
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_CRITICAL: set[str] = {"Git Repository", "Tests"}
|
|
22
|
+
|
|
23
|
+
_RECOMMENDED: set[str] = {
|
|
24
|
+
"README.md",
|
|
25
|
+
"AGENTS.md",
|
|
26
|
+
".gitignore",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"Ruff",
|
|
29
|
+
"Pytest",
|
|
30
|
+
"GitHub Actions",
|
|
31
|
+
"pyproject.toml",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_OPTIONAL: set[str] = {
|
|
35
|
+
"Dockerfile",
|
|
36
|
+
".env.example",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"Type Checking",
|
|
39
|
+
"Pre-commit",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Complete-sentence recommendation text keyed by check name
|
|
43
|
+
_REC_TEXT: dict[str, str] = {
|
|
44
|
+
"Git Repository": "Initialize a git repository with 'git init'.",
|
|
45
|
+
"Tests": "Create a tests/ directory and add test files.",
|
|
46
|
+
"README.md": "Add a README.md file to document your project.",
|
|
47
|
+
"AGENTS.md": "Add an AGENTS.md file so coding agents understand this project's conventions.",
|
|
48
|
+
".gitignore": "Add a .gitignore file to exclude generated files from version control.",
|
|
49
|
+
"LICENSE": "Add a LICENSE file to specify usage terms for your project.",
|
|
50
|
+
"Ruff": "Configure Ruff in pyproject.toml for automated code quality checks.",
|
|
51
|
+
"Pytest": "Configure Pytest in pyproject.toml or create pytest.ini.",
|
|
52
|
+
"GitHub Actions": "Set up GitHub Actions in .github/workflows/ for automated CI/CD.",
|
|
53
|
+
"pyproject.toml": "Add a pyproject.toml to centralise project configuration.",
|
|
54
|
+
"Dockerfile": "Add a Dockerfile for reproducible, containerised deployment.",
|
|
55
|
+
".env.example": "Create a .env.example file to document required environment variables.",
|
|
56
|
+
"CHANGELOG.md": "Add a CHANGELOG.md to document project history and releases.",
|
|
57
|
+
"Type Checking": "Configure a type checker (mypy or pyright) to catch type errors early.",
|
|
58
|
+
"Pre-commit": "Add a .pre-commit-config.yaml to enforce checks before every commit.",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Estimated effort per check name
|
|
62
|
+
_EFFORT: dict[str, str] = {
|
|
63
|
+
"Git Repository": "30 seconds",
|
|
64
|
+
"README.md": "2 minutes",
|
|
65
|
+
"AGENTS.md": "1 minute",
|
|
66
|
+
"LICENSE": "1 minute",
|
|
67
|
+
"CHANGELOG.md": "2 minutes",
|
|
68
|
+
".gitignore": "1 minute",
|
|
69
|
+
"pyproject.toml": "2 minutes",
|
|
70
|
+
".env.example": "2 minutes",
|
|
71
|
+
"Tests": "10 minutes",
|
|
72
|
+
"Pytest": "5 minutes",
|
|
73
|
+
"Ruff": "5 minutes",
|
|
74
|
+
"Type Checking": "5 minutes",
|
|
75
|
+
"Pre-commit": "5 minutes",
|
|
76
|
+
"GitHub Actions": "10 minutes",
|
|
77
|
+
"Dockerfile": "15 minutes",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class HealthCheck:
|
|
83
|
+
"""Represents a single health check result."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
category: str
|
|
87
|
+
passed: bool
|
|
88
|
+
message: str
|
|
89
|
+
weight: int = 10
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProjectHealthChecker:
|
|
93
|
+
"""Analyzes project health and best-practices compliance."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, project_path: Path | None = None):
|
|
96
|
+
self.project_path = project_path or Path.cwd()
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Documentation Checks
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def check_readme(self) -> HealthCheck:
|
|
103
|
+
p = self.project_path / "README.md"
|
|
104
|
+
ok = p.exists() and p.is_file()
|
|
105
|
+
return HealthCheck(
|
|
106
|
+
name="README.md",
|
|
107
|
+
category="Documentation",
|
|
108
|
+
passed=ok,
|
|
109
|
+
message="Documentation file present" if ok else "Missing README.md",
|
|
110
|
+
weight=10,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def check_agents_md(self) -> HealthCheck:
|
|
114
|
+
p = self.project_path / "AGENTS.md"
|
|
115
|
+
ok = p.exists() and p.is_file()
|
|
116
|
+
return HealthCheck(
|
|
117
|
+
name="AGENTS.md",
|
|
118
|
+
category="Documentation",
|
|
119
|
+
passed=ok,
|
|
120
|
+
message="Agent context file present" if ok else "Missing AGENTS.md",
|
|
121
|
+
weight=5,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def check_license(self) -> HealthCheck:
|
|
125
|
+
p = self.project_path / "LICENSE"
|
|
126
|
+
ok = p.exists() and p.is_file()
|
|
127
|
+
return HealthCheck(
|
|
128
|
+
name="LICENSE",
|
|
129
|
+
category="Documentation",
|
|
130
|
+
passed=ok,
|
|
131
|
+
message="License file present" if ok else "Missing LICENSE file",
|
|
132
|
+
weight=5,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def check_changelog(self) -> HealthCheck:
|
|
136
|
+
p = self.project_path / "CHANGELOG.md"
|
|
137
|
+
ok = p.exists() and p.is_file()
|
|
138
|
+
return HealthCheck(
|
|
139
|
+
name="CHANGELOG.md",
|
|
140
|
+
category="Documentation",
|
|
141
|
+
passed=ok,
|
|
142
|
+
message="Changelog present" if ok else "Missing CHANGELOG.md",
|
|
143
|
+
weight=5,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Version Control Checks
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def check_git_repository(self) -> HealthCheck:
|
|
151
|
+
p = self.project_path / ".git"
|
|
152
|
+
ok = p.exists() and p.is_dir()
|
|
153
|
+
return HealthCheck(
|
|
154
|
+
name="Git Repository",
|
|
155
|
+
category="Version Control",
|
|
156
|
+
passed=ok,
|
|
157
|
+
message="Git initialized" if ok else "Not a git repository",
|
|
158
|
+
weight=15,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def check_gitignore(self) -> HealthCheck:
|
|
162
|
+
p = self.project_path / ".gitignore"
|
|
163
|
+
ok = p.exists() and p.is_file()
|
|
164
|
+
return HealthCheck(
|
|
165
|
+
name=".gitignore",
|
|
166
|
+
category="Version Control",
|
|
167
|
+
passed=ok,
|
|
168
|
+
message="Git ignore configured" if ok else "Missing .gitignore",
|
|
169
|
+
weight=10,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Configuration Checks
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def check_env_example(self) -> HealthCheck:
|
|
177
|
+
p = self.project_path / ".env.example"
|
|
178
|
+
ok = p.exists() and p.is_file()
|
|
179
|
+
return HealthCheck(
|
|
180
|
+
name=".env.example",
|
|
181
|
+
category="Configuration",
|
|
182
|
+
passed=ok,
|
|
183
|
+
message="Environment template present" if ok else "Missing .env.example",
|
|
184
|
+
weight=5,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def check_pyproject(self) -> HealthCheck:
|
|
188
|
+
p = self.project_path / "pyproject.toml"
|
|
189
|
+
ok = p.exists() and p.is_file()
|
|
190
|
+
return HealthCheck(
|
|
191
|
+
name="pyproject.toml",
|
|
192
|
+
category="Configuration",
|
|
193
|
+
passed=ok,
|
|
194
|
+
message="Project configuration present" if ok else "Missing pyproject.toml",
|
|
195
|
+
weight=10,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Testing Checks
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def check_tests_directory(self) -> HealthCheck:
|
|
203
|
+
p = self.project_path / "tests"
|
|
204
|
+
ok = p.exists() and p.is_dir()
|
|
205
|
+
return HealthCheck(
|
|
206
|
+
name="Tests",
|
|
207
|
+
category="Testing",
|
|
208
|
+
passed=ok,
|
|
209
|
+
message="Test directory configured" if ok else "Missing tests directory",
|
|
210
|
+
weight=15,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def check_pytest_configured(self) -> HealthCheck:
|
|
214
|
+
pyproject = self.project_path / "pyproject.toml"
|
|
215
|
+
pytest_ini = self.project_path / "pytest.ini"
|
|
216
|
+
setup_cfg = self.project_path / "setup.cfg"
|
|
217
|
+
|
|
218
|
+
ok = False
|
|
219
|
+
location = None
|
|
220
|
+
|
|
221
|
+
if pytest_ini.exists():
|
|
222
|
+
ok, location = True, "pytest.ini"
|
|
223
|
+
elif setup_cfg.exists():
|
|
224
|
+
try:
|
|
225
|
+
content = setup_cfg.read_text(encoding="utf-8")
|
|
226
|
+
if "[pytest]" in content or "[tool:pytest]" in content:
|
|
227
|
+
ok, location = True, "setup.cfg"
|
|
228
|
+
except (OSError, ValueError):
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
if not ok and pyproject.exists():
|
|
232
|
+
try:
|
|
233
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
234
|
+
if "[tool.pytest" in content:
|
|
235
|
+
ok, location = True, "pyproject.toml [tool.pytest]"
|
|
236
|
+
elif "pytest>=" in content or '"pytest"' in content:
|
|
237
|
+
ok, location = True, "pyproject.toml (dependency)"
|
|
238
|
+
except (OSError, ValueError):
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
msg = f"Pytest configured in {location}" if ok else "Pytest not configured"
|
|
242
|
+
return HealthCheck(name="Pytest", category="Testing", passed=ok, message=msg, weight=10)
|
|
243
|
+
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
# Automation Checks
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def check_dockerfile(self) -> HealthCheck:
|
|
249
|
+
p = self.project_path / "Dockerfile"
|
|
250
|
+
ok = p.exists() and p.is_file()
|
|
251
|
+
return HealthCheck(
|
|
252
|
+
name="Dockerfile",
|
|
253
|
+
category="Automation",
|
|
254
|
+
passed=ok,
|
|
255
|
+
message="Docker configuration present" if ok else "Missing Dockerfile",
|
|
256
|
+
weight=10,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def check_github_actions(self) -> HealthCheck:
|
|
260
|
+
workflows = self.project_path / ".github" / "workflows"
|
|
261
|
+
count = 0
|
|
262
|
+
if workflows.exists() and workflows.is_dir():
|
|
263
|
+
count = len(list(workflows.glob("*.yml")) + list(workflows.glob("*.yaml")))
|
|
264
|
+
|
|
265
|
+
ok = count > 0
|
|
266
|
+
msg = (
|
|
267
|
+
f"GitHub Actions configured ({count} workflow{'s' if count != 1 else ''})"
|
|
268
|
+
if ok
|
|
269
|
+
else "GitHub Actions not configured"
|
|
270
|
+
)
|
|
271
|
+
return HealthCheck(
|
|
272
|
+
name="GitHub Actions",
|
|
273
|
+
category="Automation",
|
|
274
|
+
passed=ok,
|
|
275
|
+
message=msg,
|
|
276
|
+
weight=10,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
# Code Quality Checks
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def check_ruff_configured(self) -> HealthCheck:
|
|
284
|
+
pyproject = self.project_path / "pyproject.toml"
|
|
285
|
+
ruff_toml = self.project_path / "ruff.toml"
|
|
286
|
+
ruff_toml_alt = self.project_path / ".ruff.toml"
|
|
287
|
+
|
|
288
|
+
ok = False
|
|
289
|
+
location = None
|
|
290
|
+
|
|
291
|
+
if ruff_toml.exists():
|
|
292
|
+
ok, location = True, "ruff.toml"
|
|
293
|
+
elif ruff_toml_alt.exists():
|
|
294
|
+
ok, location = True, ".ruff.toml"
|
|
295
|
+
elif pyproject.exists():
|
|
296
|
+
try:
|
|
297
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
298
|
+
if "[tool.ruff]" in content:
|
|
299
|
+
ok, location = True, "pyproject.toml [tool.ruff]"
|
|
300
|
+
elif "ruff>=" in content or '"ruff"' in content:
|
|
301
|
+
ok, location = True, "pyproject.toml (dependency)"
|
|
302
|
+
except (OSError, ValueError):
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
msg = f"Ruff configured in {location}" if ok else "Ruff not configured"
|
|
306
|
+
return HealthCheck(
|
|
307
|
+
name="Ruff", category="Code Quality", passed=ok, message=msg, weight=10
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def check_type_checker_configured(self) -> HealthCheck:
|
|
311
|
+
"""Check for mypy or pyright configuration."""
|
|
312
|
+
pyproject = self.project_path / "pyproject.toml"
|
|
313
|
+
mypy_ini = self.project_path / "mypy.ini"
|
|
314
|
+
pyright_cfg = self.project_path / "pyrightconfig.json"
|
|
315
|
+
|
|
316
|
+
ok = False
|
|
317
|
+
location = None
|
|
318
|
+
|
|
319
|
+
if mypy_ini.exists():
|
|
320
|
+
ok, location = True, "mypy.ini"
|
|
321
|
+
elif pyright_cfg.exists():
|
|
322
|
+
ok, location = True, "pyrightconfig.json"
|
|
323
|
+
elif pyproject.exists():
|
|
324
|
+
try:
|
|
325
|
+
content = pyproject.read_text(encoding="utf-8")
|
|
326
|
+
if "[tool.mypy]" in content:
|
|
327
|
+
ok, location = True, "pyproject.toml [tool.mypy]"
|
|
328
|
+
elif "[tool.pyright]" in content:
|
|
329
|
+
ok, location = True, "pyproject.toml [tool.pyright]"
|
|
330
|
+
except (OSError, ValueError):
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
msg = f"Type checking configured ({location})" if ok else "Type checking not configured"
|
|
334
|
+
return HealthCheck(
|
|
335
|
+
name="Type Checking",
|
|
336
|
+
category="Code Quality",
|
|
337
|
+
passed=ok,
|
|
338
|
+
message=msg,
|
|
339
|
+
weight=10,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def check_precommit_configured(self) -> HealthCheck:
|
|
343
|
+
"""Check for .pre-commit-config.yaml."""
|
|
344
|
+
p = self.project_path / ".pre-commit-config.yaml"
|
|
345
|
+
ok = p.exists() and p.is_file()
|
|
346
|
+
return HealthCheck(
|
|
347
|
+
name="Pre-commit",
|
|
348
|
+
category="Code Quality",
|
|
349
|
+
passed=ok,
|
|
350
|
+
message="Pre-commit configured" if ok else "Pre-commit not configured",
|
|
351
|
+
weight=5,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# Aggregation
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def get_all_checks(self) -> List[Callable[[], HealthCheck]]:
|
|
359
|
+
return [
|
|
360
|
+
self.check_readme,
|
|
361
|
+
self.check_agents_md,
|
|
362
|
+
self.check_license,
|
|
363
|
+
self.check_changelog,
|
|
364
|
+
self.check_git_repository,
|
|
365
|
+
self.check_gitignore,
|
|
366
|
+
self.check_env_example,
|
|
367
|
+
self.check_pyproject,
|
|
368
|
+
self.check_tests_directory,
|
|
369
|
+
self.check_pytest_configured,
|
|
370
|
+
self.check_dockerfile,
|
|
371
|
+
self.check_github_actions,
|
|
372
|
+
self.check_ruff_configured,
|
|
373
|
+
self.check_type_checker_configured,
|
|
374
|
+
self.check_precommit_configured,
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
def run_all_checks(self) -> List[HealthCheck]:
|
|
378
|
+
return [check() for check in self.get_all_checks()]
|
|
379
|
+
|
|
380
|
+
def calculate_score(self, checks: List[HealthCheck]) -> tuple[int, int]:
|
|
381
|
+
if not checks:
|
|
382
|
+
return 0, 0
|
|
383
|
+
total = sum(c.weight for c in checks)
|
|
384
|
+
earned = sum(c.weight for c in checks if c.passed)
|
|
385
|
+
return earned, total
|
|
386
|
+
|
|
387
|
+
def calculate_category_scores(
|
|
388
|
+
self, checks: List[HealthCheck]
|
|
389
|
+
) -> dict[str, tuple[int, int]]:
|
|
390
|
+
"""Return {category: (earned_weight, total_weight)} per category."""
|
|
391
|
+
cats = self.group_checks_by_category(checks)
|
|
392
|
+
return {
|
|
393
|
+
cat: (
|
|
394
|
+
sum(c.weight for c in cs if c.passed),
|
|
395
|
+
sum(c.weight for c in cs),
|
|
396
|
+
)
|
|
397
|
+
for cat, cs in cats.items()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def group_checks_by_category(
|
|
401
|
+
self, checks: List[HealthCheck]
|
|
402
|
+
) -> dict[str, List[HealthCheck]]:
|
|
403
|
+
result: dict[str, List[HealthCheck]] = {}
|
|
404
|
+
for check in checks:
|
|
405
|
+
result.setdefault(check.category, []).append(check)
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
def get_health_rating(self, score_percent: int) -> tuple[str, str, str]:
|
|
409
|
+
"""Return (emoji, label, description) for a 0-100 score."""
|
|
410
|
+
if score_percent >= 90:
|
|
411
|
+
return ("🟢", "Excellent", "Project follows most recommended practices.")
|
|
412
|
+
if score_percent >= 70:
|
|
413
|
+
return ("🟡", "Good", "Some improvements recommended.")
|
|
414
|
+
if score_percent >= 50:
|
|
415
|
+
return ("🟠", "Fair", "Several recommended practices are missing.")
|
|
416
|
+
return ("🔴", "Needs Attention", "Core project setup is incomplete.")
|
|
417
|
+
|
|
418
|
+
def get_next_best_step(
|
|
419
|
+
self, checks: List[HealthCheck]
|
|
420
|
+
) -> tuple[str, str] | None:
|
|
421
|
+
"""Return (recommendation, effort) for the single highest-priority failed check."""
|
|
422
|
+
failed = [c for c in checks if not c.passed]
|
|
423
|
+
if not failed:
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
def _tier_key(c: HealthCheck) -> tuple[int, int]:
|
|
427
|
+
if c.name in _CRITICAL:
|
|
428
|
+
return (0, -c.weight)
|
|
429
|
+
if c.name in _RECOMMENDED:
|
|
430
|
+
return (1, -c.weight)
|
|
431
|
+
return (2, -c.weight)
|
|
432
|
+
|
|
433
|
+
top = min(failed, key=_tier_key)
|
|
434
|
+
rec = _REC_TEXT.get(top.name, f"Address: {top.name}.")
|
|
435
|
+
effort = _EFFORT.get(top.name, "a few minutes")
|
|
436
|
+
return (rec, effort)
|
|
437
|
+
|
|
438
|
+
def generate_recommendations(
|
|
439
|
+
self, checks: List[HealthCheck]
|
|
440
|
+
) -> List[str]:
|
|
441
|
+
"""Return tiered recommendations for all failed checks.
|
|
442
|
+
|
|
443
|
+
Returns a flat list ordered Critical → Recommended → Optional,
|
|
444
|
+
with each item sorted by weight (descending) within its tier.
|
|
445
|
+
Only failed checks are included.
|
|
446
|
+
"""
|
|
447
|
+
failed = [c for c in checks if not c.passed]
|
|
448
|
+
|
|
449
|
+
def _sort_key(c: HealthCheck) -> tuple[int, int]:
|
|
450
|
+
if c.name in _CRITICAL:
|
|
451
|
+
tier = 0
|
|
452
|
+
elif c.name in _RECOMMENDED:
|
|
453
|
+
tier = 1
|
|
454
|
+
else:
|
|
455
|
+
tier = 2
|
|
456
|
+
return (tier, -c.weight)
|
|
457
|
+
|
|
458
|
+
return [
|
|
459
|
+
_REC_TEXT.get(c.name, f"Address: {c.name}.")
|
|
460
|
+
for c in sorted(failed, key=_sort_key)
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
def _tiered_recs(
|
|
464
|
+
self, checks: List[HealthCheck]
|
|
465
|
+
) -> tuple[List[str], List[str], List[str]]:
|
|
466
|
+
"""Return (critical_recs, recommended_recs, optional_recs) — failed checks only."""
|
|
467
|
+
failed = [c for c in checks if not c.passed]
|
|
468
|
+
critical, recommended, optional = [], [], []
|
|
469
|
+
for c in sorted(failed, key=lambda x: -x.weight):
|
|
470
|
+
text = _REC_TEXT.get(c.name, f"Address: {c.name}.")
|
|
471
|
+
if c.name in _CRITICAL:
|
|
472
|
+
critical.append(text)
|
|
473
|
+
elif c.name in _RECOMMENDED:
|
|
474
|
+
recommended.append(text)
|
|
475
|
+
else:
|
|
476
|
+
optional.append(text)
|
|
477
|
+
return critical, recommended, optional
|
|
478
|
+
|
|
479
|
+
def format_report(self, checks: List[HealthCheck]) -> None:
|
|
480
|
+
score, max_score = self.calculate_score(checks)
|
|
481
|
+
score_percent = int((score / max_score * 100) if max_score > 0 else 0)
|
|
482
|
+
emoji, label, description = self.get_health_rating(score_percent)
|
|
483
|
+
categories = self.group_checks_by_category(checks)
|
|
484
|
+
category_scores = self.calculate_category_scores(checks)
|
|
485
|
+
|
|
486
|
+
content = Text()
|
|
487
|
+
|
|
488
|
+
# Health rating
|
|
489
|
+
content.append(f"{emoji} {label}\n", style="bold")
|
|
490
|
+
content.append(f"{description}\n", style="dim")
|
|
491
|
+
content.append("\n")
|
|
492
|
+
|
|
493
|
+
# Score
|
|
494
|
+
if score_percent >= 80:
|
|
495
|
+
score_color = "green"
|
|
496
|
+
elif score_percent >= 50:
|
|
497
|
+
score_color = "yellow"
|
|
498
|
+
else:
|
|
499
|
+
score_color = "red"
|
|
500
|
+
content.append("Project Score: ", style="bold")
|
|
501
|
+
content.append(f"{score_percent}% ({score}/{max_score})\n", style=f"bold {score_color}")
|
|
502
|
+
|
|
503
|
+
category_order = [
|
|
504
|
+
"Documentation",
|
|
505
|
+
"Version Control",
|
|
506
|
+
"Configuration",
|
|
507
|
+
"Testing",
|
|
508
|
+
"Automation",
|
|
509
|
+
"Code Quality",
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
for category in category_order:
|
|
513
|
+
if category not in categories:
|
|
514
|
+
continue
|
|
515
|
+
cat_earned, cat_total = category_scores.get(category, (0, 0))
|
|
516
|
+
cat_pct = int((cat_earned / cat_total * 100) if cat_total else 0)
|
|
517
|
+
content.append(f"\n{category} — {cat_pct}%\n", style="bold cyan")
|
|
518
|
+
|
|
519
|
+
for check in categories[category]:
|
|
520
|
+
if check.passed:
|
|
521
|
+
status, style = "✓", "green"
|
|
522
|
+
else:
|
|
523
|
+
status, style = "⚠", "yellow"
|
|
524
|
+
content.append(f" {status} ", style=style)
|
|
525
|
+
content.append(check.name, style=f"bold {style}")
|
|
526
|
+
content.append(f" — {check.message}\n", style=style)
|
|
527
|
+
|
|
528
|
+
console.print()
|
|
529
|
+
console.print(
|
|
530
|
+
Panel(
|
|
531
|
+
content,
|
|
532
|
+
title="[bold cyan]🏥 Project Health Report[/bold cyan]",
|
|
533
|
+
border_style=BORDER_COLOR,
|
|
534
|
+
padding=(1, 2),
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Tiered recommendations panel
|
|
539
|
+
critical, recommended, optional = self._tiered_recs(checks)
|
|
540
|
+
if critical or recommended or optional:
|
|
541
|
+
rec_content = Text()
|
|
542
|
+
if critical:
|
|
543
|
+
rec_content.append("Critical\n", style="bold red")
|
|
544
|
+
for r in critical:
|
|
545
|
+
rec_content.append(f" • {r}\n", style="red")
|
|
546
|
+
if recommended:
|
|
547
|
+
if critical:
|
|
548
|
+
rec_content.append("\n")
|
|
549
|
+
rec_content.append("Recommended\n", style="bold yellow")
|
|
550
|
+
for r in recommended:
|
|
551
|
+
rec_content.append(f" • {r}\n", style="yellow")
|
|
552
|
+
if optional:
|
|
553
|
+
if critical or recommended:
|
|
554
|
+
rec_content.append("\n")
|
|
555
|
+
rec_content.append("Optional\n", style="bold dim")
|
|
556
|
+
for r in optional:
|
|
557
|
+
rec_content.append(f" • {r}\n", style="dim")
|
|
558
|
+
|
|
559
|
+
console.print()
|
|
560
|
+
console.print(
|
|
561
|
+
Panel(
|
|
562
|
+
rec_content,
|
|
563
|
+
title="[bold yellow]💡 Recommendations[/bold yellow]",
|
|
564
|
+
border_style="yellow",
|
|
565
|
+
padding=(1, 2),
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Next Best Step panel
|
|
570
|
+
nbs = self.get_next_best_step(checks)
|
|
571
|
+
if nbs:
|
|
572
|
+
rec_text, effort = nbs
|
|
573
|
+
nbs_content = Text()
|
|
574
|
+
nbs_content.append(f"{rec_text}\n\n", style="bold")
|
|
575
|
+
nbs_content.append("Estimated effort: ", style="dim")
|
|
576
|
+
nbs_content.append(effort, style="bold dim")
|
|
577
|
+
console.print()
|
|
578
|
+
console.print(
|
|
579
|
+
Panel(
|
|
580
|
+
nbs_content,
|
|
581
|
+
title="[bold green]🎯 Next Best Step[/bold green]",
|
|
582
|
+
border_style="green",
|
|
583
|
+
padding=(1, 2),
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
console.print()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def run_health_check(project_path: Path | None = None) -> None:
|
|
591
|
+
"""CLI entry point for `spawn doctor`."""
|
|
592
|
+
checker = ProjectHealthChecker(project_path)
|
|
593
|
+
checks = checker.run_all_checks()
|
|
594
|
+
checker.format_report(checks)
|