crackerjack 0.29.0__py3-none-any.whl โ 0.31.4__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.
- crackerjack/CLAUDE.md +1005 -0
- crackerjack/RULES.md +380 -0
- crackerjack/__init__.py +42 -13
- crackerjack/__main__.py +225 -253
- crackerjack/agents/__init__.py +41 -0
- crackerjack/agents/architect_agent.py +281 -0
- crackerjack/agents/base.py +169 -0
- crackerjack/agents/coordinator.py +512 -0
- crackerjack/agents/documentation_agent.py +498 -0
- crackerjack/agents/dry_agent.py +388 -0
- crackerjack/agents/formatting_agent.py +245 -0
- crackerjack/agents/import_optimization_agent.py +281 -0
- crackerjack/agents/performance_agent.py +669 -0
- crackerjack/agents/proactive_agent.py +104 -0
- crackerjack/agents/refactoring_agent.py +788 -0
- crackerjack/agents/security_agent.py +529 -0
- crackerjack/agents/test_creation_agent.py +652 -0
- crackerjack/agents/test_specialist_agent.py +486 -0
- crackerjack/agents/tracker.py +212 -0
- crackerjack/api.py +560 -0
- crackerjack/cli/__init__.py +24 -0
- crackerjack/cli/facade.py +104 -0
- crackerjack/cli/handlers.py +267 -0
- crackerjack/cli/interactive.py +471 -0
- crackerjack/cli/options.py +401 -0
- crackerjack/cli/utils.py +18 -0
- crackerjack/code_cleaner.py +670 -0
- crackerjack/config/__init__.py +19 -0
- crackerjack/config/hooks.py +218 -0
- crackerjack/core/__init__.py +0 -0
- crackerjack/core/async_workflow_orchestrator.py +406 -0
- crackerjack/core/autofix_coordinator.py +200 -0
- crackerjack/core/container.py +104 -0
- crackerjack/core/enhanced_container.py +542 -0
- crackerjack/core/performance.py +243 -0
- crackerjack/core/phase_coordinator.py +561 -0
- crackerjack/core/proactive_workflow.py +316 -0
- crackerjack/core/session_coordinator.py +289 -0
- crackerjack/core/workflow_orchestrator.py +640 -0
- crackerjack/dynamic_config.py +577 -0
- crackerjack/errors.py +263 -41
- crackerjack/executors/__init__.py +11 -0
- crackerjack/executors/async_hook_executor.py +431 -0
- crackerjack/executors/cached_hook_executor.py +242 -0
- crackerjack/executors/hook_executor.py +345 -0
- crackerjack/executors/individual_hook_executor.py +669 -0
- crackerjack/intelligence/__init__.py +44 -0
- crackerjack/intelligence/adaptive_learning.py +751 -0
- crackerjack/intelligence/agent_orchestrator.py +551 -0
- crackerjack/intelligence/agent_registry.py +414 -0
- crackerjack/intelligence/agent_selector.py +502 -0
- crackerjack/intelligence/integration.py +290 -0
- crackerjack/interactive.py +576 -315
- crackerjack/managers/__init__.py +11 -0
- crackerjack/managers/async_hook_manager.py +135 -0
- crackerjack/managers/hook_manager.py +137 -0
- crackerjack/managers/publish_manager.py +411 -0
- crackerjack/managers/test_command_builder.py +151 -0
- crackerjack/managers/test_executor.py +435 -0
- crackerjack/managers/test_manager.py +258 -0
- crackerjack/managers/test_manager_backup.py +1124 -0
- crackerjack/managers/test_progress.py +144 -0
- crackerjack/mcp/__init__.py +0 -0
- crackerjack/mcp/cache.py +336 -0
- crackerjack/mcp/client_runner.py +104 -0
- crackerjack/mcp/context.py +615 -0
- crackerjack/mcp/dashboard.py +636 -0
- crackerjack/mcp/enhanced_progress_monitor.py +479 -0
- crackerjack/mcp/file_monitor.py +336 -0
- crackerjack/mcp/progress_components.py +569 -0
- crackerjack/mcp/progress_monitor.py +949 -0
- crackerjack/mcp/rate_limiter.py +332 -0
- crackerjack/mcp/server.py +22 -0
- crackerjack/mcp/server_core.py +244 -0
- crackerjack/mcp/service_watchdog.py +501 -0
- crackerjack/mcp/state.py +395 -0
- crackerjack/mcp/task_manager.py +257 -0
- crackerjack/mcp/tools/__init__.py +17 -0
- crackerjack/mcp/tools/core_tools.py +249 -0
- crackerjack/mcp/tools/error_analyzer.py +308 -0
- crackerjack/mcp/tools/execution_tools.py +370 -0
- crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
- crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
- crackerjack/mcp/tools/intelligence_tools.py +314 -0
- crackerjack/mcp/tools/monitoring_tools.py +502 -0
- crackerjack/mcp/tools/proactive_tools.py +384 -0
- crackerjack/mcp/tools/progress_tools.py +141 -0
- crackerjack/mcp/tools/utility_tools.py +341 -0
- crackerjack/mcp/tools/workflow_executor.py +360 -0
- crackerjack/mcp/websocket/__init__.py +14 -0
- crackerjack/mcp/websocket/app.py +39 -0
- crackerjack/mcp/websocket/endpoints.py +559 -0
- crackerjack/mcp/websocket/jobs.py +253 -0
- crackerjack/mcp/websocket/server.py +116 -0
- crackerjack/mcp/websocket/websocket_handler.py +78 -0
- crackerjack/mcp/websocket_server.py +10 -0
- crackerjack/models/__init__.py +31 -0
- crackerjack/models/config.py +93 -0
- crackerjack/models/config_adapter.py +230 -0
- crackerjack/models/protocols.py +118 -0
- crackerjack/models/task.py +154 -0
- crackerjack/monitoring/ai_agent_watchdog.py +450 -0
- crackerjack/monitoring/regression_prevention.py +638 -0
- crackerjack/orchestration/__init__.py +0 -0
- crackerjack/orchestration/advanced_orchestrator.py +970 -0
- crackerjack/orchestration/execution_strategies.py +341 -0
- crackerjack/orchestration/test_progress_streamer.py +636 -0
- crackerjack/plugins/__init__.py +15 -0
- crackerjack/plugins/base.py +200 -0
- crackerjack/plugins/hooks.py +246 -0
- crackerjack/plugins/loader.py +335 -0
- crackerjack/plugins/managers.py +259 -0
- crackerjack/py313.py +8 -3
- crackerjack/services/__init__.py +22 -0
- crackerjack/services/cache.py +314 -0
- crackerjack/services/config.py +347 -0
- crackerjack/services/config_integrity.py +99 -0
- crackerjack/services/contextual_ai_assistant.py +516 -0
- crackerjack/services/coverage_ratchet.py +347 -0
- crackerjack/services/debug.py +736 -0
- crackerjack/services/dependency_monitor.py +617 -0
- crackerjack/services/enhanced_filesystem.py +439 -0
- crackerjack/services/file_hasher.py +151 -0
- crackerjack/services/filesystem.py +395 -0
- crackerjack/services/git.py +165 -0
- crackerjack/services/health_metrics.py +611 -0
- crackerjack/services/initialization.py +847 -0
- crackerjack/services/log_manager.py +286 -0
- crackerjack/services/logging.py +174 -0
- crackerjack/services/metrics.py +578 -0
- crackerjack/services/pattern_cache.py +362 -0
- crackerjack/services/pattern_detector.py +515 -0
- crackerjack/services/performance_benchmarks.py +653 -0
- crackerjack/services/security.py +163 -0
- crackerjack/services/server_manager.py +234 -0
- crackerjack/services/smart_scheduling.py +144 -0
- crackerjack/services/tool_version_service.py +61 -0
- crackerjack/services/unified_config.py +437 -0
- crackerjack/services/version_checker.py +248 -0
- crackerjack/slash_commands/__init__.py +14 -0
- crackerjack/slash_commands/init.md +122 -0
- crackerjack/slash_commands/run.md +163 -0
- crackerjack/slash_commands/status.md +127 -0
- crackerjack-0.31.4.dist-info/METADATA +742 -0
- crackerjack-0.31.4.dist-info/RECORD +148 -0
- crackerjack-0.31.4.dist-info/entry_points.txt +2 -0
- crackerjack/.gitignore +0 -34
- crackerjack/.libcst.codemod.yaml +0 -18
- crackerjack/.pdm.toml +0 -1
- crackerjack/.pre-commit-config-ai.yaml +0 -149
- crackerjack/.pre-commit-config-fast.yaml +0 -69
- crackerjack/.pre-commit-config.yaml +0 -114
- crackerjack/crackerjack.py +0 -4140
- crackerjack/pyproject.toml +0 -285
- crackerjack-0.29.0.dist-info/METADATA +0 -1289
- crackerjack-0.29.0.dist-info/RECORD +0 -17
- {crackerjack-0.29.0.dist-info โ crackerjack-0.31.4.dist-info}/WHEEL +0 -0
- {crackerjack-0.29.0.dist-info โ crackerjack-0.31.4.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"
|