crackerjack 0.30.3__py3-none-any.whl โ†’ 0.31.7__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.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

Files changed (156) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +227 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +170 -0
  8. crackerjack/agents/coordinator.py +512 -0
  9. crackerjack/agents/documentation_agent.py +498 -0
  10. crackerjack/agents/dry_agent.py +388 -0
  11. crackerjack/agents/formatting_agent.py +245 -0
  12. crackerjack/agents/import_optimization_agent.py +281 -0
  13. crackerjack/agents/performance_agent.py +669 -0
  14. crackerjack/agents/proactive_agent.py +104 -0
  15. crackerjack/agents/refactoring_agent.py +788 -0
  16. crackerjack/agents/security_agent.py +529 -0
  17. crackerjack/agents/test_creation_agent.py +657 -0
  18. crackerjack/agents/test_specialist_agent.py +486 -0
  19. crackerjack/agents/tracker.py +212 -0
  20. crackerjack/api.py +560 -0
  21. crackerjack/cli/__init__.py +24 -0
  22. crackerjack/cli/facade.py +104 -0
  23. crackerjack/cli/handlers.py +267 -0
  24. crackerjack/cli/interactive.py +471 -0
  25. crackerjack/cli/options.py +409 -0
  26. crackerjack/cli/utils.py +18 -0
  27. crackerjack/code_cleaner.py +618 -928
  28. crackerjack/config/__init__.py +19 -0
  29. crackerjack/config/hooks.py +218 -0
  30. crackerjack/core/__init__.py +0 -0
  31. crackerjack/core/async_workflow_orchestrator.py +406 -0
  32. crackerjack/core/autofix_coordinator.py +200 -0
  33. crackerjack/core/container.py +104 -0
  34. crackerjack/core/enhanced_container.py +542 -0
  35. crackerjack/core/performance.py +243 -0
  36. crackerjack/core/phase_coordinator.py +585 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +826 -0
  40. crackerjack/dynamic_config.py +94 -103
  41. crackerjack/errors.py +263 -41
  42. crackerjack/executors/__init__.py +11 -0
  43. crackerjack/executors/async_hook_executor.py +431 -0
  44. crackerjack/executors/cached_hook_executor.py +242 -0
  45. crackerjack/executors/hook_executor.py +345 -0
  46. crackerjack/executors/individual_hook_executor.py +669 -0
  47. crackerjack/intelligence/__init__.py +44 -0
  48. crackerjack/intelligence/adaptive_learning.py +751 -0
  49. crackerjack/intelligence/agent_orchestrator.py +551 -0
  50. crackerjack/intelligence/agent_registry.py +414 -0
  51. crackerjack/intelligence/agent_selector.py +502 -0
  52. crackerjack/intelligence/integration.py +290 -0
  53. crackerjack/interactive.py +576 -315
  54. crackerjack/managers/__init__.py +11 -0
  55. crackerjack/managers/async_hook_manager.py +135 -0
  56. crackerjack/managers/hook_manager.py +137 -0
  57. crackerjack/managers/publish_manager.py +433 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +443 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +114 -0
  63. crackerjack/mcp/__init__.py +0 -0
  64. crackerjack/mcp/cache.py +336 -0
  65. crackerjack/mcp/client_runner.py +104 -0
  66. crackerjack/mcp/context.py +621 -0
  67. crackerjack/mcp/dashboard.py +636 -0
  68. crackerjack/mcp/enhanced_progress_monitor.py +479 -0
  69. crackerjack/mcp/file_monitor.py +336 -0
  70. crackerjack/mcp/progress_components.py +569 -0
  71. crackerjack/mcp/progress_monitor.py +949 -0
  72. crackerjack/mcp/rate_limiter.py +332 -0
  73. crackerjack/mcp/server.py +22 -0
  74. crackerjack/mcp/server_core.py +244 -0
  75. crackerjack/mcp/service_watchdog.py +501 -0
  76. crackerjack/mcp/state.py +395 -0
  77. crackerjack/mcp/task_manager.py +257 -0
  78. crackerjack/mcp/tools/__init__.py +17 -0
  79. crackerjack/mcp/tools/core_tools.py +249 -0
  80. crackerjack/mcp/tools/error_analyzer.py +308 -0
  81. crackerjack/mcp/tools/execution_tools.py +372 -0
  82. crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
  83. crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
  84. crackerjack/mcp/tools/intelligence_tools.py +314 -0
  85. crackerjack/mcp/tools/monitoring_tools.py +502 -0
  86. crackerjack/mcp/tools/proactive_tools.py +384 -0
  87. crackerjack/mcp/tools/progress_tools.py +217 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +565 -0
  90. crackerjack/mcp/websocket/__init__.py +14 -0
  91. crackerjack/mcp/websocket/app.py +39 -0
  92. crackerjack/mcp/websocket/endpoints.py +559 -0
  93. crackerjack/mcp/websocket/jobs.py +253 -0
  94. crackerjack/mcp/websocket/server.py +116 -0
  95. crackerjack/mcp/websocket/websocket_handler.py +78 -0
  96. crackerjack/mcp/websocket_server.py +10 -0
  97. crackerjack/models/__init__.py +31 -0
  98. crackerjack/models/config.py +93 -0
  99. crackerjack/models/config_adapter.py +230 -0
  100. crackerjack/models/protocols.py +118 -0
  101. crackerjack/models/task.py +154 -0
  102. crackerjack/monitoring/ai_agent_watchdog.py +450 -0
  103. crackerjack/monitoring/regression_prevention.py +638 -0
  104. crackerjack/orchestration/__init__.py +0 -0
  105. crackerjack/orchestration/advanced_orchestrator.py +970 -0
  106. crackerjack/orchestration/coverage_improvement.py +223 -0
  107. crackerjack/orchestration/execution_strategies.py +341 -0
  108. crackerjack/orchestration/test_progress_streamer.py +636 -0
  109. crackerjack/plugins/__init__.py +15 -0
  110. crackerjack/plugins/base.py +200 -0
  111. crackerjack/plugins/hooks.py +246 -0
  112. crackerjack/plugins/loader.py +335 -0
  113. crackerjack/plugins/managers.py +259 -0
  114. crackerjack/py313.py +8 -3
  115. crackerjack/services/__init__.py +22 -0
  116. crackerjack/services/cache.py +314 -0
  117. crackerjack/services/config.py +358 -0
  118. crackerjack/services/config_integrity.py +99 -0
  119. crackerjack/services/contextual_ai_assistant.py +516 -0
  120. crackerjack/services/coverage_ratchet.py +356 -0
  121. crackerjack/services/debug.py +736 -0
  122. crackerjack/services/dependency_monitor.py +617 -0
  123. crackerjack/services/enhanced_filesystem.py +439 -0
  124. crackerjack/services/file_hasher.py +151 -0
  125. crackerjack/services/filesystem.py +421 -0
  126. crackerjack/services/git.py +176 -0
  127. crackerjack/services/health_metrics.py +611 -0
  128. crackerjack/services/initialization.py +873 -0
  129. crackerjack/services/log_manager.py +286 -0
  130. crackerjack/services/logging.py +174 -0
  131. crackerjack/services/metrics.py +578 -0
  132. crackerjack/services/pattern_cache.py +362 -0
  133. crackerjack/services/pattern_detector.py +515 -0
  134. crackerjack/services/performance_benchmarks.py +653 -0
  135. crackerjack/services/security.py +163 -0
  136. crackerjack/services/server_manager.py +234 -0
  137. crackerjack/services/smart_scheduling.py +144 -0
  138. crackerjack/services/tool_version_service.py +61 -0
  139. crackerjack/services/unified_config.py +437 -0
  140. crackerjack/services/version_checker.py +248 -0
  141. crackerjack/slash_commands/__init__.py +14 -0
  142. crackerjack/slash_commands/init.md +122 -0
  143. crackerjack/slash_commands/run.md +163 -0
  144. crackerjack/slash_commands/status.md +127 -0
  145. crackerjack-0.31.7.dist-info/METADATA +742 -0
  146. crackerjack-0.31.7.dist-info/RECORD +149 -0
  147. crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
  148. crackerjack/.gitignore +0 -34
  149. crackerjack/.libcst.codemod.yaml +0 -18
  150. crackerjack/.pdm.toml +0 -1
  151. crackerjack/crackerjack.py +0 -3805
  152. crackerjack/pyproject.toml +0 -286
  153. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  154. crackerjack-0.30.3.dist-info/RECORD +0 -16
  155. {crackerjack-0.30.3.dist-info โ†’ crackerjack-0.31.7.dist-info}/WHEEL +0 -0
  156. {crackerjack-0.30.3.dist-info โ†’ crackerjack-0.31.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,611 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ import tomllib
5
+ import typing as t
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+
14
+ from crackerjack.models.protocols import FileSystemInterface
15
+
16
+
17
+ @dataclass
18
+ class ProjectHealth:
19
+ lint_error_trend: list[int] = field(default_factory=list)
20
+ test_coverage_trend: list[float] = field(default_factory=list)
21
+ dependency_age: dict[str, int] = field(default_factory=dict)
22
+ config_completeness: float = 0.0
23
+ last_updated: float = field(default_factory=time.time)
24
+
25
+ def needs_init(self) -> bool:
26
+ if self._is_trending_up(self.lint_error_trend):
27
+ return True
28
+
29
+ if self._is_trending_down(self.test_coverage_trend):
30
+ return True
31
+
32
+ if any(age > 180 for age in self.dependency_age.values()):
33
+ return True
34
+
35
+ return self.config_completeness < 0.8
36
+
37
+ def _is_trending_up(
38
+ self, values: list[int] | list[float], min_points: int = 3
39
+ ) -> bool:
40
+ if len(values) < min_points:
41
+ return False
42
+
43
+ recent = values[-min_points:]
44
+ # Performance: Use pairwise comparison with zip
45
+ return all(a <= b for a, b in zip(recent, recent[1:]))
46
+
47
+ def _is_trending_down(
48
+ self, values: list[int] | list[float], min_points: int = 3
49
+ ) -> bool:
50
+ if len(values) < min_points:
51
+ return False
52
+
53
+ recent = values[-min_points:]
54
+ # Performance: Use pairwise comparison with zip
55
+ return all(a >= b for a, b in zip(recent, recent[1:]))
56
+
57
+ def get_health_score(self) -> float:
58
+ scores: list[float] = []
59
+
60
+ if self.lint_error_trend:
61
+ recent_errors = sum(self.lint_error_trend[-5:]) / min(
62
+ len(self.lint_error_trend),
63
+ 5,
64
+ )
65
+ lint_score = max(0, 1.0 - (recent_errors / 100))
66
+ scores.append(lint_score)
67
+
68
+ if self.test_coverage_trend:
69
+ recent_coverage = sum(self.test_coverage_trend[-5:]) / min(
70
+ len(self.test_coverage_trend),
71
+ 5,
72
+ )
73
+ coverage_score = recent_coverage / 100.0
74
+ scores.append(coverage_score)
75
+
76
+ if self.dependency_age:
77
+ avg_age = sum(self.dependency_age.values()) / len(self.dependency_age)
78
+
79
+ dependency_score = max(0, 1.0 - (avg_age / 365))
80
+ scores.append(dependency_score)
81
+
82
+ scores.append(self.config_completeness)
83
+
84
+ return sum(scores) / len(scores) if scores else 0.0
85
+
86
+ def get_recommendations(self) -> list[str]:
87
+ recommendations: list[str] = []
88
+
89
+ if self._is_trending_up(self.lint_error_trend):
90
+ recommendations.append(
91
+ "๐Ÿ”ง Lint errors are increasing - consider running formatting tools",
92
+ )
93
+
94
+ if self._is_trending_down(self.test_coverage_trend):
95
+ recommendations.append("๐Ÿงช Test coverage is declining - add more tests")
96
+
97
+ if any(age > 365 for age in self.dependency_age.values()):
98
+ old_deps: list[str] = [
99
+ pkg for pkg, age in self.dependency_age.items() if age > 365
100
+ ]
101
+ recommendations.append(
102
+ f"๐Ÿ“ฆ Very old dependencies detected: {', '.join(old_deps[:3])}",
103
+ )
104
+
105
+ if self.config_completeness < 0.5:
106
+ recommendations.append(
107
+ "โš™๏ธ Project configuration is incomplete - run crackerjack init",
108
+ )
109
+ elif self.config_completeness < 0.8:
110
+ recommendations.append("โš™๏ธ Project configuration could be improved")
111
+
112
+ if len(self.lint_error_trend) > 10:
113
+ recent_avg = sum(self.lint_error_trend[-5:]) / 5
114
+ older_avg = sum(self.lint_error_trend[-10:-5]) / 5
115
+ if recent_avg > older_avg * 1.5:
116
+ recommendations.append(
117
+ "๐Ÿ“ˆ Quality is degrading rapidly - immediate attention needed",
118
+ )
119
+
120
+ return recommendations
121
+
122
+
123
+ class HealthMetricsService:
124
+ def __init__(
125
+ self,
126
+ filesystem: FileSystemInterface,
127
+ console: Console | None = None,
128
+ ) -> None:
129
+ self.filesystem = filesystem
130
+ self.console = console or Console()
131
+ self.project_root = Path.cwd()
132
+ self.pyproject_path = self.project_root / "pyproject.toml"
133
+ self.health_cache = self.project_root / ".crackerjack" / "health_metrics.json"
134
+ self.max_trend_points = 20
135
+
136
+ def collect_current_metrics(self) -> ProjectHealth:
137
+ health = self._load_health_history()
138
+
139
+ lint_errors = self._count_lint_errors()
140
+ if lint_errors is not None:
141
+ health.lint_error_trend.append(lint_errors)
142
+ health.lint_error_trend = health.lint_error_trend[-self.max_trend_points :]
143
+
144
+ coverage = self._get_test_coverage()
145
+ if coverage is not None:
146
+ health.test_coverage_trend.append(coverage)
147
+ health.test_coverage_trend = health.test_coverage_trend[
148
+ -self.max_trend_points :
149
+ ]
150
+
151
+ health.dependency_age = self._calculate_dependency_ages()
152
+
153
+ health.config_completeness = self._assess_config_completeness()
154
+
155
+ health.last_updated = time.time()
156
+
157
+ return health
158
+
159
+ def _load_health_history(self) -> ProjectHealth:
160
+ with suppress(Exception):
161
+ if self.health_cache.exists():
162
+ with self.health_cache.open() as f:
163
+ data = json.load(f)
164
+ return ProjectHealth(**data)
165
+
166
+ return ProjectHealth()
167
+
168
+ def _save_health_metrics(self, health: ProjectHealth) -> None:
169
+ try:
170
+ self.health_cache.parent.mkdir(exist_ok=True)
171
+ with self.health_cache.open("w") as f:
172
+ data = {
173
+ "lint_error_trend": health.lint_error_trend,
174
+ "test_coverage_trend": health.test_coverage_trend,
175
+ "dependency_age": health.dependency_age,
176
+ "config_completeness": health.config_completeness,
177
+ "last_updated": health.last_updated,
178
+ }
179
+ json.dump(data, f, indent=2)
180
+ except Exception as e:
181
+ self.console.print(
182
+ f"[yellow]Warning: Failed to save health metrics: {e}[/yellow]",
183
+ )
184
+
185
+ def _count_lint_errors(self) -> int | None:
186
+ with suppress(Exception):
187
+ result = subprocess.run(
188
+ ["uv", "run", "ruff", "check", ".", "--output-format=json"],
189
+ check=False,
190
+ capture_output=True,
191
+ text=True,
192
+ timeout=30,
193
+ cwd=self.project_root,
194
+ )
195
+
196
+ if result.returncode == 0:
197
+ return 0
198
+
199
+ if result.stdout:
200
+ try:
201
+ lint_data = json.loads(result.stdout)
202
+ return len(lint_data) if isinstance(lint_data, list) else 0
203
+ except json.JSONDecodeError:
204
+ return len(result.stdout.splitlines())
205
+
206
+ return None
207
+
208
+ def _get_test_coverage(self) -> float | None:
209
+ with suppress(Exception):
210
+ existing_coverage = self._check_existing_coverage_files()
211
+ if existing_coverage is not None:
212
+ return existing_coverage
213
+
214
+ generated_coverage = self._generate_coverage_report()
215
+ if generated_coverage is not None:
216
+ return generated_coverage
217
+
218
+ return self._get_coverage_from_command()
219
+
220
+ return None
221
+
222
+ def _check_existing_coverage_files(self) -> float | None:
223
+ coverage_files = [
224
+ self.project_root / ".coverage",
225
+ self.project_root / "htmlcov" / "index.html",
226
+ self.project_root / "coverage.xml",
227
+ ]
228
+
229
+ for coverage_file in coverage_files:
230
+ if coverage_file.exists():
231
+ return self._get_coverage_from_command()
232
+
233
+ return None
234
+
235
+ def _generate_coverage_report(self) -> float | None:
236
+ subprocess.run(
237
+ [
238
+ "uv",
239
+ "run",
240
+ "python",
241
+ "-m",
242
+ "pytest",
243
+ "--cov=.",
244
+ "--cov-report=json",
245
+ "--tb=no",
246
+ "-q",
247
+ "--maxfail=1",
248
+ ],
249
+ check=False,
250
+ capture_output=True,
251
+ text=True,
252
+ timeout=60,
253
+ cwd=self.project_root,
254
+ )
255
+
256
+ coverage_json = self.project_root / "coverage.json"
257
+ if coverage_json.exists():
258
+ with coverage_json.open() as f:
259
+ data = json.load(f)
260
+ return float(data.get("totals", {}).get("percent_covered", 0))
261
+
262
+ return None
263
+
264
+ def _get_coverage_from_command(self) -> float | None:
265
+ result = subprocess.run(
266
+ ["uv", "run", "coverage", "report", "--format=json"],
267
+ check=False,
268
+ capture_output=True,
269
+ text=True,
270
+ timeout=15,
271
+ cwd=self.project_root,
272
+ )
273
+
274
+ if result.returncode == 0 and result.stdout:
275
+ data = json.loads(result.stdout)
276
+ return float(data.get("totals", {}).get("percent_covered", 0))
277
+
278
+ return None
279
+
280
+ def _calculate_dependency_ages(self) -> dict[str, int]:
281
+ dependency_ages: dict[str, int] = {}
282
+
283
+ with suppress(Exception):
284
+ if not self.pyproject_path.exists():
285
+ return dependency_ages
286
+
287
+ project_data = self._load_project_data()
288
+ dependencies = self._extract_all_dependencies(project_data)
289
+ dependency_ages = self._get_ages_for_dependencies(dependencies)
290
+
291
+ return dependency_ages
292
+
293
+ def _load_project_data(self) -> dict[str, t.Any]:
294
+ with self.pyproject_path.open("rb") as f:
295
+ return tomllib.load(f)
296
+
297
+ def _extract_all_dependencies(self, project_data: dict[str, t.Any]) -> list[str]:
298
+ dependencies: list[str] = []
299
+
300
+ if "dependencies" in project_data.get("project", {}):
301
+ dependencies.extend(project_data["project"]["dependencies"])
302
+
303
+ if "optional-dependencies" in project_data.get("project", {}):
304
+ for group_deps in project_data["project"]["optional-dependencies"].values():
305
+ dependencies.extend(group_deps)
306
+
307
+ return dependencies
308
+
309
+ def _get_ages_for_dependencies(self, dependencies: list[str]) -> dict[str, int]:
310
+ dependency_ages: dict[str, int] = {}
311
+
312
+ for dep_spec in dependencies:
313
+ package_name = self._extract_package_name(dep_spec)
314
+ if package_name:
315
+ age = self._get_package_age(package_name)
316
+ if age is not None:
317
+ dependency_ages[package_name] = age
318
+
319
+ return dependency_ages
320
+
321
+ def _extract_package_name(self, dep_spec: str) -> str | None:
322
+ if not dep_spec or dep_spec.startswith("-"):
323
+ return None
324
+
325
+ for operator in (">=", "<=", "==", "~=", "!=", ">", "<"):
326
+ if operator in dep_spec:
327
+ return dep_spec.split(operator)[0].strip()
328
+
329
+ return dep_spec.strip()
330
+
331
+ def _get_package_age(self, package_name: str) -> int | None:
332
+ try:
333
+ package_data = self._fetch_package_data(package_name)
334
+ if not package_data:
335
+ return None
336
+
337
+ upload_time = self._extract_upload_time(package_data)
338
+ if not upload_time:
339
+ return None
340
+
341
+ return self._calculate_days_since_upload(upload_time)
342
+ except Exception:
343
+ return None
344
+
345
+ def _fetch_package_data(self, package_name: str) -> dict[str, t.Any] | None:
346
+ try:
347
+ import urllib.request
348
+
349
+ url = f"https://pypi.org/pypi/{package_name}/json"
350
+
351
+ if not url.startswith("https://pypi.org/"):
352
+ msg = f"Invalid URL scheme: {url}"
353
+ raise ValueError(msg)
354
+
355
+ with urllib.request.urlopen(url, timeout=10) as response: # nosec B310
356
+ return json.load(response)
357
+ except Exception:
358
+ return None
359
+
360
+ def _extract_upload_time(self, package_data: dict[str, t.Any]) -> str | None:
361
+ info = package_data.get("info", {})
362
+ releases = package_data.get("releases", {})
363
+
364
+ latest_version = info.get("version", "")
365
+ if not latest_version or latest_version not in releases:
366
+ return None
367
+
368
+ release_info = releases[latest_version]
369
+ if not release_info:
370
+ return None
371
+
372
+ return release_info[0].get("upload_time", "")
373
+
374
+ def _calculate_days_since_upload(self, upload_time: str) -> int | None:
375
+ try:
376
+ upload_date = datetime.fromisoformat(upload_time)
377
+ return (datetime.now(upload_date.tzinfo) - upload_date).days
378
+ except Exception:
379
+ return None
380
+
381
+ def _assess_config_completeness(self) -> float:
382
+ score = 0.0
383
+ total_checks = 0
384
+
385
+ pyproject_score, pyproject_checks = self._assess_pyproject_config()
386
+ score += pyproject_score
387
+ total_checks += pyproject_checks
388
+
389
+ precommit_score, precommit_checks = self._assess_precommit_config()
390
+ score += precommit_score
391
+ total_checks += precommit_checks
392
+
393
+ ci_score, ci_checks = self._assess_ci_config()
394
+ score += ci_score
395
+ total_checks += ci_checks
396
+
397
+ doc_score, doc_checks = self._assess_documentation_config()
398
+ score += doc_score
399
+ total_checks += doc_checks
400
+
401
+ return min(1.0, score) if total_checks > 0 else 0.0
402
+
403
+ def _assess_pyproject_config(self) -> tuple[float, int]:
404
+ score = 0.0
405
+ total_checks = 1
406
+
407
+ if not self.pyproject_path.exists():
408
+ return score, total_checks
409
+
410
+ score += 0.2
411
+
412
+ with suppress(Exception):
413
+ with self.pyproject_path.open("rb") as f:
414
+ data = tomllib.load(f)
415
+
416
+ project_score, project_checks = self._assess_project_metadata(data)
417
+ score += project_score
418
+ total_checks += project_checks
419
+
420
+ tool_score, tool_checks = self._assess_tool_configs(data)
421
+ score += tool_score
422
+ total_checks += tool_checks
423
+
424
+ return score, total_checks
425
+
426
+ def _assess_project_metadata(self, data: dict[str, t.Any]) -> tuple[float, int]:
427
+ score = 0.0
428
+ total_checks = 0
429
+
430
+ if "project" not in data:
431
+ return score, total_checks
432
+
433
+ project_data = data["project"]
434
+ essential_fields = ["name", "version", "description", "dependencies"]
435
+
436
+ for field_name in essential_fields:
437
+ total_checks += 1
438
+ if field_name in project_data:
439
+ score += 0.1
440
+
441
+ return score, total_checks
442
+
443
+ def _assess_tool_configs(self, data: dict[str, t.Any]) -> tuple[float, int]:
444
+ score = 0.0
445
+ tool_configs = ["tool.ruff", "tool.pytest", "tool.coverage"]
446
+
447
+ for tool in tool_configs:
448
+ keys = tool.split(".")
449
+ current = data
450
+ with suppress(KeyError):
451
+ for key in keys:
452
+ current = current[key]
453
+ score += 0.05
454
+
455
+ return score, len(tool_configs)
456
+
457
+ def _assess_precommit_config(self) -> tuple[float, int]:
458
+ precommit_files = [
459
+ self.project_root / ".pre-commit-config.yaml",
460
+ self.project_root / ".pre-commit-config.yml",
461
+ ]
462
+ score = 0.1 if any(f.exists() for f in precommit_files) else 0.0
463
+ return score, 1
464
+
465
+ def _assess_ci_config(self) -> tuple[float, int]:
466
+ ci_files = [
467
+ self.project_root / ".github" / "workflows",
468
+ self.project_root / ".gitlab-ci.yml",
469
+ self.project_root / "azure-pipelines.yml",
470
+ ]
471
+ score = 0.1 if any(f.exists() for f in ci_files) else 0.0
472
+ return score, 1
473
+
474
+ def _assess_documentation_config(self) -> tuple[float, int]:
475
+ doc_files = [
476
+ self.project_root / "README.md",
477
+ self.project_root / "README.rst",
478
+ self.project_root / "docs",
479
+ ]
480
+ score = 0.1 if any(f.exists() for f in doc_files) else 0.0
481
+ return score, 1
482
+
483
+ def analyze_project_health(self, save_metrics: bool = True) -> ProjectHealth:
484
+ health = self.collect_current_metrics()
485
+
486
+ if save_metrics:
487
+ self._save_health_metrics(health)
488
+
489
+ return health
490
+
491
+ def report_health_status(self, health: ProjectHealth) -> None:
492
+ """Generate and display comprehensive project health report."""
493
+ health_score = health.get_health_score()
494
+
495
+ self._print_health_summary(health_score)
496
+ self._print_health_metrics(health)
497
+ self._print_health_recommendations(health)
498
+
499
+ def _print_health_summary(self, health_score: float) -> None:
500
+ """Print the overall health score with appropriate styling."""
501
+ status_icon, status_text, status_color = self._get_health_status_display(
502
+ health_score,
503
+ )
504
+
505
+ self.console.print("\n[bold]๐Ÿ“Š Project Health Report[/bold]")
506
+ self.console.print(
507
+ f"{status_icon} Overall Health: [{status_color}]{status_text} ({health_score:.1%})[/{status_color}]",
508
+ )
509
+
510
+ def _get_health_status_display(self, health_score: float) -> tuple[str, str, str]:
511
+ """Get display elements (icon, text, color) for health score."""
512
+ if health_score >= 0.8:
513
+ return "๐ŸŸข", "Excellent", "green"
514
+ if health_score >= 0.6:
515
+ return "๐ŸŸก", "Good", "yellow"
516
+ if health_score >= 0.4:
517
+ return "๐ŸŸ ", "Fair", "orange"
518
+ return "๐Ÿ”ด", "Poor", "red"
519
+
520
+ def _print_health_metrics(self, health: ProjectHealth) -> None:
521
+ """Print detailed health metrics."""
522
+ if health.lint_error_trend:
523
+ recent_errors = health.lint_error_trend[-1]
524
+ self.console.print(f"๐Ÿ”ง Lint Errors: {recent_errors}")
525
+
526
+ if health.test_coverage_trend:
527
+ recent_coverage = health.test_coverage_trend[-1]
528
+ self.console.print(f"๐Ÿงช Test Coverage: {recent_coverage:.1f}%")
529
+
530
+ if health.dependency_age:
531
+ avg_age = sum(health.dependency_age.values()) / len(health.dependency_age)
532
+ self.console.print(f"๐Ÿ“ฆ Avg Dependency Age: {avg_age:.0f} days")
533
+
534
+ self.console.print(f"โš™๏ธ Config Completeness: {health.config_completeness:.1%}")
535
+
536
+ def _print_health_recommendations(self, health: ProjectHealth) -> None:
537
+ """Print health recommendations and init suggestions."""
538
+ recommendations = health.get_recommendations()
539
+ if recommendations:
540
+ self.console.print("\n[bold]๐Ÿ’ก Recommendations:[/bold]")
541
+ for rec in recommendations:
542
+ self.console.print(f" {rec}")
543
+
544
+ if health.needs_init():
545
+ self.console.print(
546
+ "\n[bold yellow]โš ๏ธ Consider running `crackerjack --init` to improve project health[/bold yellow]",
547
+ )
548
+
549
+ def get_health_trend_summary(self, days: int = 30) -> dict[str, Any]:
550
+ health = self._load_health_history()
551
+
552
+ return {
553
+ "health_score": health.get_health_score(),
554
+ "needs_attention": health.needs_init(),
555
+ "recommendations": health.get_recommendations(),
556
+ "metrics": {
557
+ "lint_errors": self._get_lint_errors_metrics(health),
558
+ "test_coverage": self._get_test_coverage_metrics(health),
559
+ "dependency_age": self._get_dependency_age_metrics(health),
560
+ "config_completeness": health.config_completeness,
561
+ },
562
+ }
563
+
564
+ def _get_lint_errors_metrics(
565
+ self, health: ProjectHealth
566
+ ) -> dict[str, str | int | None]:
567
+ return {
568
+ "current": health.lint_error_trend[-1] if health.lint_error_trend else None,
569
+ "trend": self._get_trend_direction(health, health.lint_error_trend),
570
+ }
571
+
572
+ def _get_test_coverage_metrics(
573
+ self, health: ProjectHealth
574
+ ) -> dict[str, str | float | None]:
575
+ return {
576
+ "current": health.test_coverage_trend[-1]
577
+ if health.test_coverage_trend
578
+ else None,
579
+ "trend": self._get_coverage_trend_direction(
580
+ health, health.test_coverage_trend
581
+ ),
582
+ }
583
+
584
+ def _get_dependency_age_metrics(
585
+ self, health: ProjectHealth
586
+ ) -> dict[str, float | int | None]:
587
+ if not health.dependency_age:
588
+ return {"average": None, "outdated_count": 0}
589
+
590
+ return {
591
+ "average": sum(health.dependency_age.values()) / len(health.dependency_age),
592
+ "outdated_count": sum(
593
+ 1 for age in health.dependency_age.values() if age > 180
594
+ ),
595
+ }
596
+
597
+ def _get_trend_direction(self, health: ProjectHealth, trend_data: list[int]) -> str:
598
+ if health._is_trending_up([float(x) for x in trend_data]):
599
+ return "up"
600
+ elif health._is_trending_down([float(x) for x in trend_data]):
601
+ return "down"
602
+ return "stable"
603
+
604
+ def _get_coverage_trend_direction(
605
+ self, health: ProjectHealth, coverage_trend: list[float]
606
+ ) -> str:
607
+ if health._is_trending_up([int(x) for x in coverage_trend]):
608
+ return "up"
609
+ elif health._is_trending_down(coverage_trend):
610
+ return "down"
611
+ return "stable"