crackerjack 0.30.3__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.

Files changed (155) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +225 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +169 -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 +652 -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 +401 -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 +561 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +640 -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 +411 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +435 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +144 -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 +615 -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 +370 -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 +141 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +360 -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/execution_strategies.py +341 -0
  107. crackerjack/orchestration/test_progress_streamer.py +636 -0
  108. crackerjack/plugins/__init__.py +15 -0
  109. crackerjack/plugins/base.py +200 -0
  110. crackerjack/plugins/hooks.py +246 -0
  111. crackerjack/plugins/loader.py +335 -0
  112. crackerjack/plugins/managers.py +259 -0
  113. crackerjack/py313.py +8 -3
  114. crackerjack/services/__init__.py +22 -0
  115. crackerjack/services/cache.py +314 -0
  116. crackerjack/services/config.py +347 -0
  117. crackerjack/services/config_integrity.py +99 -0
  118. crackerjack/services/contextual_ai_assistant.py +516 -0
  119. crackerjack/services/coverage_ratchet.py +347 -0
  120. crackerjack/services/debug.py +736 -0
  121. crackerjack/services/dependency_monitor.py +617 -0
  122. crackerjack/services/enhanced_filesystem.py +439 -0
  123. crackerjack/services/file_hasher.py +151 -0
  124. crackerjack/services/filesystem.py +395 -0
  125. crackerjack/services/git.py +165 -0
  126. crackerjack/services/health_metrics.py +611 -0
  127. crackerjack/services/initialization.py +847 -0
  128. crackerjack/services/log_manager.py +286 -0
  129. crackerjack/services/logging.py +174 -0
  130. crackerjack/services/metrics.py +578 -0
  131. crackerjack/services/pattern_cache.py +362 -0
  132. crackerjack/services/pattern_detector.py +515 -0
  133. crackerjack/services/performance_benchmarks.py +653 -0
  134. crackerjack/services/security.py +163 -0
  135. crackerjack/services/server_manager.py +234 -0
  136. crackerjack/services/smart_scheduling.py +144 -0
  137. crackerjack/services/tool_version_service.py +61 -0
  138. crackerjack/services/unified_config.py +437 -0
  139. crackerjack/services/version_checker.py +248 -0
  140. crackerjack/slash_commands/__init__.py +14 -0
  141. crackerjack/slash_commands/init.md +122 -0
  142. crackerjack/slash_commands/run.md +163 -0
  143. crackerjack/slash_commands/status.md +127 -0
  144. crackerjack-0.31.4.dist-info/METADATA +742 -0
  145. crackerjack-0.31.4.dist-info/RECORD +148 -0
  146. crackerjack-0.31.4.dist-info/entry_points.txt +2 -0
  147. crackerjack/.gitignore +0 -34
  148. crackerjack/.libcst.codemod.yaml +0 -18
  149. crackerjack/.pdm.toml +0 -1
  150. crackerjack/crackerjack.py +0 -3805
  151. crackerjack/pyproject.toml +0 -286
  152. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  153. crackerjack-0.30.3.dist-info/RECORD +0 -16
  154. {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.dist-info}/WHEEL +0 -0
  155. {crackerjack-0.30.3.dist-info → crackerjack-0.31.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,431 @@
1
+ import asyncio
2
+ import time
3
+ import typing as t
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from crackerjack.config.hooks import HookDefinition, HookStrategy, RetryPolicy
10
+ from crackerjack.models.task import HookResult
11
+ from crackerjack.services.logging import LoggingContext, get_logger
12
+
13
+
14
+ @dataclass
15
+ class AsyncHookExecutionResult:
16
+ strategy_name: str
17
+ results: list[HookResult]
18
+ total_duration: float
19
+ success: bool
20
+ concurrent_execution: bool = True
21
+ cache_hits: int = 0
22
+ cache_misses: int = 0
23
+ performance_gain: float = 0.0
24
+
25
+ @property
26
+ def failed_count(self) -> int:
27
+ return sum(1 for r in self.results if r.status == "failed")
28
+
29
+ @property
30
+ def passed_count(self) -> int:
31
+ return sum(1 for r in self.results if r.status == "passed")
32
+
33
+ @property
34
+ def cache_hit_rate(self) -> float:
35
+ total_requests = self.cache_hits + self.cache_misses
36
+ return (self.cache_hits / total_requests * 100) if total_requests > 0 else 0.0
37
+
38
+ @property
39
+ def performance_summary(self) -> dict[str, t.Any]:
40
+ return {
41
+ "total_hooks": len(self.results),
42
+ "passed": self.passed_count,
43
+ "failed": self.failed_count,
44
+ "duration_seconds": round(self.total_duration, 2),
45
+ "concurrent": self.concurrent_execution,
46
+ "cache_hits": self.cache_hits,
47
+ "cache_misses": self.cache_misses,
48
+ "cache_hit_rate_percent": round(self.cache_hit_rate, 1),
49
+ "performance_gain_percent": round(self.performance_gain, 1),
50
+ }
51
+
52
+
53
+ class AsyncHookExecutor:
54
+ def __init__(
55
+ self,
56
+ console: Console,
57
+ pkg_path: Path,
58
+ max_concurrent: int = 4,
59
+ timeout: int = 300,
60
+ quiet: bool = False,
61
+ ) -> None:
62
+ self.console = console
63
+ self.pkg_path = pkg_path
64
+ self.max_concurrent = max_concurrent
65
+ self.timeout = timeout
66
+ self.quiet = quiet
67
+ self.logger = get_logger("crackerjack.async_hook_executor")
68
+
69
+ self._semaphore = asyncio.Semaphore(max_concurrent)
70
+
71
+ async def execute_strategy(
72
+ self,
73
+ strategy: HookStrategy,
74
+ ) -> AsyncHookExecutionResult:
75
+ with LoggingContext(
76
+ "async_hook_strategy",
77
+ strategy_name=strategy.name,
78
+ hook_count=len(strategy.hooks),
79
+ ):
80
+ start_time = time.time()
81
+ self.logger.info(
82
+ "Starting async hook strategy execution",
83
+ strategy=strategy.name,
84
+ hooks=len(strategy.hooks),
85
+ parallel=strategy.parallel,
86
+ max_workers=getattr(strategy, "max_workers", self.max_concurrent),
87
+ )
88
+
89
+ self._print_strategy_header(strategy)
90
+
91
+ estimated_sequential = sum(
92
+ getattr(hook, "timeout", 30) for hook in strategy.hooks
93
+ )
94
+
95
+ if strategy.parallel and len(strategy.hooks) > 1:
96
+ results = await self._execute_parallel(strategy)
97
+ else:
98
+ results = await self._execute_sequential(strategy)
99
+
100
+ if strategy.retry_policy != RetryPolicy.NONE:
101
+ results = await self._handle_retries(strategy, results)
102
+
103
+ total_duration = time.time() - start_time
104
+ success = all(r.status == "passed" for r in results)
105
+ performance_gain = max(
106
+ 0,
107
+ ((estimated_sequential - total_duration) / estimated_sequential) * 100,
108
+ )
109
+
110
+ self.logger.info(
111
+ "Async hook strategy completed",
112
+ strategy=strategy.name,
113
+ success=success,
114
+ duration_seconds=round(total_duration, 2),
115
+ performance_gain_percent=round(performance_gain, 1),
116
+ passed=sum(1 for r in results if r.status == "passed"),
117
+ failed=sum(1 for r in results if r.status == "failed"),
118
+ errors=sum(1 for r in results if r.status in ("timeout", "error")),
119
+ )
120
+
121
+ if not self.quiet:
122
+ self._print_summary(strategy, results, success, performance_gain)
123
+
124
+ return AsyncHookExecutionResult(
125
+ strategy_name=strategy.name,
126
+ results=results,
127
+ total_duration=total_duration,
128
+ success=success,
129
+ performance_gain=performance_gain,
130
+ )
131
+
132
+ def _print_strategy_header(self, strategy: HookStrategy) -> None:
133
+ self.console.print("\n" + "-" * 80)
134
+ if strategy.name == "fast":
135
+ self.console.print(
136
+ "[bold bright_cyan]🔍 HOOKS[/bold bright_cyan] [bold bright_white]Running code quality checks (async)[/bold bright_white]",
137
+ )
138
+ elif strategy.name == "comprehensive":
139
+ self.console.print(
140
+ "[bold bright_cyan]🔍 HOOKS[/bold bright_cyan] [bold bright_white]Running comprehensive quality checks (async)[/bold bright_white]",
141
+ )
142
+ else:
143
+ self.console.print(
144
+ f"[bold bright_cyan]🔍 HOOKS[/bold bright_cyan] [bold bright_white]Running {strategy.name} hooks (async)[/bold bright_white]",
145
+ )
146
+ self.console.print("-" * 80 + "\n")
147
+
148
+ async def _execute_sequential(self, strategy: HookStrategy) -> list[HookResult]:
149
+ results: list[HookResult] = []
150
+ for hook in strategy.hooks:
151
+ result = await self._execute_single_hook(hook)
152
+ results.append(result)
153
+ self._display_hook_result(result)
154
+ return results
155
+
156
+ async def _execute_parallel(self, strategy: HookStrategy) -> list[HookResult]:
157
+ results: list[HookResult] = []
158
+
159
+ formatting_hooks = [
160
+ h for h in strategy.hooks if getattr(h, "is_formatting", False)
161
+ ]
162
+ other_hooks = [
163
+ h for h in strategy.hooks if not getattr(h, "is_formatting", False)
164
+ ]
165
+
166
+ for hook in formatting_hooks:
167
+ result = await self._execute_single_hook(hook)
168
+ results.append(result)
169
+ self._display_hook_result(result)
170
+
171
+ if other_hooks:
172
+ tasks = [self._execute_single_hook(hook) for hook in other_hooks]
173
+ parallel_results = await asyncio.gather(*tasks, return_exceptions=True)
174
+
175
+ for i, result in enumerate(parallel_results):
176
+ if isinstance(result, Exception):
177
+ hook = other_hooks[i]
178
+ error_result = HookResult(
179
+ id=getattr(hook, "name", f"hook_{i}"),
180
+ name=getattr(hook, "name", f"hook_{i}"),
181
+ status="error",
182
+ duration=0.0,
183
+ issues_found=[str(result)],
184
+ stage=hook.stage.value,
185
+ )
186
+ results.append(error_result)
187
+ self._display_hook_result(error_result)
188
+ else:
189
+ hook_result = t.cast("HookResult", result)
190
+ results.append(hook_result)
191
+ self._display_hook_result(hook_result)
192
+
193
+ return results
194
+
195
+ async def _execute_single_hook(self, hook: HookDefinition) -> HookResult:
196
+ async with self._semaphore:
197
+ return await self._run_hook_subprocess(hook)
198
+
199
+ async def _run_hook_subprocess(self, hook: HookDefinition) -> HookResult:
200
+ start_time = time.time()
201
+
202
+ try:
203
+ cmd = hook.get_command() if hasattr(hook, "get_command") else [str(hook)]
204
+ timeout_val = getattr(hook, "timeout", self.timeout)
205
+
206
+ self.logger.debug(
207
+ "Starting hook execution",
208
+ hook=hook.name,
209
+ command=" ".join(cmd),
210
+ timeout=timeout_val,
211
+ )
212
+
213
+ process = await asyncio.create_subprocess_exec(
214
+ *cmd,
215
+ cwd=self.pkg_path,
216
+ stdout=asyncio.subprocess.PIPE,
217
+ stderr=asyncio.subprocess.PIPE,
218
+ )
219
+
220
+ try:
221
+ stdout, stderr = await asyncio.wait_for(
222
+ process.communicate(),
223
+ timeout=timeout_val,
224
+ )
225
+ except TimeoutError:
226
+ process.kill()
227
+ await process.wait()
228
+ duration = time.time() - start_time
229
+
230
+ self.logger.warning(
231
+ "Hook execution timed out",
232
+ hook=hook.name,
233
+ timeout=timeout_val,
234
+ duration_seconds=round(duration, 2),
235
+ )
236
+
237
+ return HookResult(
238
+ id=hook.name,
239
+ name=hook.name,
240
+ status="timeout",
241
+ duration=duration,
242
+ issues_found=[f"Hook timed out after {duration: .1f}s"],
243
+ stage=hook.stage.value,
244
+ )
245
+
246
+ duration = time.time() - start_time
247
+ output_text = (
248
+ (stdout.decode() + stderr.decode()) if stdout and stderr else ""
249
+ )
250
+ return_code = process.returncode if process.returncode is not None else -1
251
+ parsed_output = self._parse_hook_output(return_code, output_text)
252
+
253
+ status = "passed" if return_code == 0 else "failed"
254
+
255
+ self.logger.info(
256
+ "Hook execution completed",
257
+ hook=hook.name,
258
+ status=status,
259
+ duration_seconds=round(duration, 2),
260
+ return_code=process.returncode,
261
+ files_processed=parsed_output.get("files_processed", 0),
262
+ issues_count=len(parsed_output.get("issues", [])),
263
+ )
264
+
265
+ return HookResult(
266
+ id=parsed_output.get("hook_id", hook.name),
267
+ name=hook.name,
268
+ status=status,
269
+ duration=duration,
270
+ files_processed=parsed_output.get("files_processed", 0),
271
+ issues_found=parsed_output.get("issues", []),
272
+ stage=hook.stage.value,
273
+ )
274
+
275
+ except Exception as e:
276
+ duration = time.time() - start_time
277
+ self.logger.exception(
278
+ "Hook execution failed with exception",
279
+ hook=hook.name,
280
+ error=str(e),
281
+ error_type=type(e).__name__,
282
+ duration_seconds=round(duration, 2),
283
+ )
284
+ return HookResult(
285
+ id=hook.name,
286
+ name=hook.name,
287
+ status="error",
288
+ duration=duration,
289
+ issues_found=[str(e)],
290
+ stage=hook.stage.value,
291
+ )
292
+
293
+ def _parse_hook_output(self, returncode: int, output: str) -> dict[str, t.Any]:
294
+ return {
295
+ "hook_id": None,
296
+ "exit_code": returncode,
297
+ "files_processed": 0,
298
+ "issues": [],
299
+ "raw_output": output,
300
+ }
301
+
302
+ def _display_hook_result(self, result: HookResult) -> None:
303
+ dots = "." * (60 - len(result.name))
304
+ status_text = "Passed" if result.status == "passed" else "Failed"
305
+ status_color = "green" if result.status == "passed" else "red"
306
+
307
+ self.console.print(
308
+ f"{result.name}{dots}[{status_color}]{status_text}[/{status_color}]",
309
+ )
310
+
311
+ if result.status != "passed" and result.issues_found:
312
+ for issue in result.issues_found:
313
+ if issue and "raw_output" not in issue:
314
+ self.console.print(issue)
315
+
316
+ async def _handle_retries(
317
+ self,
318
+ strategy: HookStrategy,
319
+ results: list[HookResult],
320
+ ) -> list[HookResult]:
321
+ if strategy.retry_policy == RetryPolicy.FORMATTING_ONLY:
322
+ return await self._retry_formatting_hooks(strategy, results)
323
+ if strategy.retry_policy == RetryPolicy.ALL_HOOKS:
324
+ return await self._retry_all_hooks(strategy, results)
325
+ return results
326
+
327
+ async def _retry_formatting_hooks(
328
+ self,
329
+ strategy: HookStrategy,
330
+ results: list[HookResult],
331
+ ) -> list[HookResult]:
332
+ formatting_hooks_failed: set[str] = set()
333
+
334
+ for i, result in enumerate(results):
335
+ hook = strategy.hooks[i]
336
+ if getattr(hook, "is_formatting", False) and result.status == "failed":
337
+ formatting_hooks_failed.add(hook.name)
338
+
339
+ if not formatting_hooks_failed:
340
+ return results
341
+
342
+ retry_tasks = [self._execute_single_hook(hook) for hook in strategy.hooks]
343
+ retry_results = await asyncio.gather(*retry_tasks, return_exceptions=True)
344
+
345
+ updated_results: list[HookResult] = []
346
+ for i, (prev_result, new_result) in enumerate(
347
+ zip(results, retry_results, strict=False)
348
+ ):
349
+ if isinstance(new_result, Exception):
350
+ hook = strategy.hooks[i]
351
+ error_result = HookResult(
352
+ id=hook.name,
353
+ name=hook.name,
354
+ status="error",
355
+ duration=prev_result.duration,
356
+ issues_found=[str(new_result)],
357
+ stage=hook.stage.value,
358
+ )
359
+ updated_results.append(error_result)
360
+ else:
361
+ hook_result = t.cast("HookResult", new_result)
362
+ hook_result.duration += prev_result.duration
363
+ updated_results.append(hook_result)
364
+
365
+ self._display_hook_result(updated_results[-1])
366
+
367
+ return updated_results
368
+
369
+ async def _retry_all_hooks(
370
+ self,
371
+ strategy: HookStrategy,
372
+ results: list[HookResult],
373
+ ) -> list[HookResult]:
374
+ failed_indices = [i for i, r in enumerate(results) if r.status == "failed"]
375
+
376
+ if not failed_indices:
377
+ return results
378
+
379
+ updated_results = results.copy()
380
+ retry_tasks: list[t.Awaitable[HookResult]] = []
381
+ retry_indices: list[int] = []
382
+
383
+ for i in failed_indices:
384
+ hook = strategy.hooks[i]
385
+ retry_tasks.append(self._execute_single_hook(hook))
386
+ retry_indices.append(i)
387
+
388
+ retry_results = await asyncio.gather(*retry_tasks, return_exceptions=True)
389
+
390
+ for result_idx, new_result in zip(retry_indices, retry_results, strict=False):
391
+ prev_result = results[result_idx]
392
+
393
+ if isinstance(new_result, Exception):
394
+ hook = strategy.hooks[result_idx]
395
+ error_result = HookResult(
396
+ id=hook.name,
397
+ name=hook.name,
398
+ status="error",
399
+ duration=prev_result.duration,
400
+ issues_found=[str(new_result)],
401
+ stage=hook.stage.value,
402
+ )
403
+ updated_results[result_idx] = error_result
404
+ else:
405
+ hook_result = t.cast("HookResult", new_result)
406
+ hook_result.duration += prev_result.duration
407
+ updated_results[result_idx] = hook_result
408
+
409
+ self._display_hook_result(updated_results[result_idx])
410
+
411
+ return updated_results
412
+
413
+ def _print_summary(
414
+ self,
415
+ strategy: HookStrategy,
416
+ results: list[HookResult],
417
+ success: bool,
418
+ performance_gain: float,
419
+ ) -> None:
420
+ if success:
421
+ self.console.print(
422
+ f"[green]✅[/green] {strategy.name.title()} hooks passed: {len(results)} / {len(results)} "
423
+ f"(async, {performance_gain: .1f} % faster)",
424
+ )
425
+ else:
426
+ failed_count = sum(1 for r in results if r.status == "failed")
427
+ error_count = sum(1 for r in results if r.status in ("timeout", "error"))
428
+ self.console.print(
429
+ f"[red]❌[/red] {strategy.name.title()} hooks failed: {failed_count} failed, {error_count} errors "
430
+ f"(async, {performance_gain: .1f} % faster)",
431
+ )
@@ -0,0 +1,242 @@
1
+ import logging
2
+ import time
3
+ import typing as t
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+
8
+ from crackerjack.config.hooks import HookDefinition, HookStrategy
9
+ from crackerjack.models.task import HookResult
10
+ from crackerjack.services.cache import CrackerjackCache
11
+ from crackerjack.services.file_hasher import FileHasher
12
+
13
+ from .hook_executor import HookExecutionResult, HookExecutor
14
+
15
+
16
+ class CachedHookExecutor:
17
+ def __init__(
18
+ self,
19
+ console: Console,
20
+ pkg_path: Path,
21
+ cache: CrackerjackCache | None = None,
22
+ cache_ttl_seconds: int = 1800,
23
+ ) -> None:
24
+ self.console = console
25
+ self.pkg_path = pkg_path
26
+ self.cache = cache or CrackerjackCache()
27
+ self.cache_ttl_seconds = cache_ttl_seconds
28
+ self.file_hasher = FileHasher(self.cache)
29
+ self.base_executor = HookExecutor(console, pkg_path, quiet=True)
30
+ self.logger = logging.getLogger("crackerjack.cached_executor")
31
+
32
+ self.file_patterns = {
33
+ "python": [" * .py"],
34
+ "config": [" * .toml", " * .cfg", " * .ini", " * .yaml", " * .yml"],
35
+ "all": [
36
+ " * .py",
37
+ " * .toml",
38
+ " * .cfg",
39
+ " * .ini",
40
+ " * .yaml",
41
+ " * .yml",
42
+ " * .md",
43
+ " * .txt",
44
+ ],
45
+ }
46
+
47
+ def execute_strategy(self, strategy: HookStrategy) -> HookExecutionResult:
48
+ self.logger.info(
49
+ f"Executing cached strategy '{strategy.name}' with {len(strategy.hooks)} hooks",
50
+ )
51
+
52
+ start_time = time.time()
53
+ results: list[HookResult] = []
54
+ cache_hits = 0
55
+ cache_misses = 0
56
+
57
+ relevant_files = self._get_relevant_files_for_strategy(strategy)
58
+ current_file_hashes = self.file_hasher.get_files_hash_list(relevant_files)
59
+
60
+ for hook_def in strategy.hooks:
61
+ cached_result = None
62
+ try:
63
+ cached_result = self.cache.get_hook_result(
64
+ hook_def.name,
65
+ current_file_hashes,
66
+ )
67
+ except Exception as e:
68
+ self.logger.warning(f"Cache error for hook {hook_def.name}: {e}")
69
+ cached_result = None
70
+
71
+ if cached_result and self._is_cache_valid(cached_result, hook_def):
72
+ self.logger.debug(f"Using cached result for hook: {hook_def.name}")
73
+ results.append(cached_result)
74
+ cache_hits += 1
75
+ else:
76
+ self.logger.debug(f"Executing hook (cache miss): {hook_def.name}")
77
+
78
+ hook_result = self.base_executor.execute_single_hook(hook_def)
79
+ results.append(hook_result)
80
+ cache_misses += 1
81
+
82
+ if hook_result.status == "passed":
83
+ try:
84
+ self.cache.set_hook_result(
85
+ hook_def.name,
86
+ current_file_hashes,
87
+ hook_result,
88
+ )
89
+ except Exception as e:
90
+ self.logger.warning(
91
+ f"Failed to cache result for {hook_def.name}: {e}",
92
+ )
93
+
94
+ total_time = time.time() - start_time
95
+ success = all(result.status == "passed" for result in results)
96
+
97
+ self.logger.info(
98
+ f"Cached strategy '{strategy.name}' completed in {total_time: .2f}s - "
99
+ f"Success: {success}, Cache hits: {cache_hits}, Cache misses: {cache_misses}",
100
+ )
101
+
102
+ return HookExecutionResult(
103
+ strategy_name=strategy.name,
104
+ results=results,
105
+ success=success,
106
+ total_duration=total_time,
107
+ cache_hits=cache_hits,
108
+ cache_misses=cache_misses,
109
+ )
110
+
111
+ def _get_relevant_files_for_strategy(self, strategy: HookStrategy) -> list[Path]:
112
+ if self._strategy_affects_python_only(strategy):
113
+ patterns = self.file_patterns["python"]
114
+ elif self._strategy_affects_config_only(strategy):
115
+ patterns = self.file_patterns["config"]
116
+ else:
117
+ patterns = self.file_patterns["all"]
118
+
119
+ files: list[Path] = []
120
+ for pattern in patterns:
121
+ files.extend(list(self.pkg_path.rglob(pattern)))
122
+
123
+ return [f for f in files if f.is_file() and not self._should_ignore_file(f)]
124
+
125
+ def _strategy_affects_python_only(self, strategy: HookStrategy) -> bool:
126
+ python_only_hooks = {
127
+ "ruff - format",
128
+ "ruff - check",
129
+ "pyright",
130
+ "bandit",
131
+ "vulture",
132
+ "refurb",
133
+ "complexipy",
134
+ }
135
+ return all(hook.name in python_only_hooks for hook in strategy.hooks)
136
+
137
+ def _strategy_affects_config_only(self, strategy: HookStrategy) -> bool:
138
+ config_only_hooks = {"creosote"}
139
+ return all(hook.name in config_only_hooks for hook in strategy.hooks)
140
+
141
+ def _should_ignore_file(self, file_path: Path) -> bool:
142
+ ignore_patterns = [
143
+ ".git/",
144
+ ".venv/",
145
+ "__pycache__/",
146
+ ".pytest_cache/",
147
+ ".coverage",
148
+ ".crackerjack_cache/",
149
+ "node_modules/",
150
+ ".tox/",
151
+ "dist/",
152
+ "build/",
153
+ ".egg - info/",
154
+ ]
155
+
156
+ path_str = str(file_path)
157
+ return any(pattern in path_str for pattern in ignore_patterns)
158
+
159
+ def _is_cache_valid(
160
+ self,
161
+ cached_result: HookResult,
162
+ hook_def: HookDefinition,
163
+ ) -> bool:
164
+ if cached_result.status != "passed":
165
+ return False
166
+
167
+ cache_age = time.time() - getattr(cached_result, "timestamp", time.time())
168
+ return not cache_age > self.cache_ttl_seconds
169
+
170
+ def invalidate_hook_cache(self, hook_name: str | None = None) -> None:
171
+ self.cache.invalidate_hook_cache(hook_name)
172
+ self.logger.info(f"Invalidated cache for hook: {hook_name or 'all hooks'}")
173
+
174
+ def get_cache_stats(self) -> dict[str, t.Any]:
175
+ return self.cache.get_cache_stats()
176
+
177
+ def cleanup_cache(self) -> dict[str, int]:
178
+ return self.cache.cleanup_all()
179
+
180
+
181
+ class SmartCacheManager:
182
+ def __init__(self, cached_executor: CachedHookExecutor) -> None:
183
+ self.cached_executor = cached_executor
184
+ self.logger = logging.getLogger("crackerjack.cache_manager")
185
+
186
+ def should_use_cache_for_hook(
187
+ self,
188
+ hook_name: str,
189
+ project_state: dict[str, t.Any],
190
+ ) -> bool:
191
+ external_hooks = {"detect - secrets"}
192
+ if hook_name in external_hooks:
193
+ return False
194
+
195
+ expensive_hooks = {"pyright", "bandit", "vulture", "complexipy"}
196
+ if hook_name in expensive_hooks:
197
+ return True
198
+
199
+ formatting_hooks = {
200
+ "ruff-format",
201
+ "trailing-whitespace",
202
+ "end-of-file-fixer",
203
+ }
204
+ if hook_name in formatting_hooks:
205
+ recent_changes = project_state.get("recent_changes", 0)
206
+ return recent_changes < 5
207
+
208
+ return True
209
+
210
+ def get_optimal_cache_strategy(
211
+ self,
212
+ hook_strategy: HookStrategy,
213
+ ) -> dict[str, bool]:
214
+ project_state = self._analyze_project_state()
215
+
216
+ cache_decisions = {}
217
+ for hook_def in hook_strategy.hooks:
218
+ cache_decisions[hook_def.name] = self.should_use_cache_for_hook(
219
+ hook_def.name,
220
+ project_state,
221
+ )
222
+
223
+ return cache_decisions
224
+
225
+ def _analyze_project_state(self) -> dict[str, t.Any]:
226
+ pkg_path = self.cached_executor.pkg_path
227
+
228
+ recent_changes = 0
229
+ for py_file in pkg_path.rglob("*.py"):
230
+ if py_file.is_file():
231
+ from contextlib import suppress
232
+
233
+ with suppress(OSError):
234
+ mtime = py_file.stat().st_mtime
235
+ if time.time() - mtime < 3600:
236
+ recent_changes += 1
237
+
238
+ return {
239
+ "recent_changes": recent_changes,
240
+ "total_python_files": len(list(pkg_path.rglob("*.py"))),
241
+ "project_size": "large" if recent_changes > 50 else "small",
242
+ }