ctrlcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrlcode/__init__.py +8 -0
- ctrlcode/agents/__init__.py +29 -0
- ctrlcode/agents/cleanup.py +388 -0
- ctrlcode/agents/communication.py +439 -0
- ctrlcode/agents/observability.py +421 -0
- ctrlcode/agents/react_loop.py +297 -0
- ctrlcode/agents/registry.py +211 -0
- ctrlcode/agents/result_parser.py +242 -0
- ctrlcode/agents/workflow.py +723 -0
- ctrlcode/analysis/__init__.py +28 -0
- ctrlcode/analysis/ast_diff.py +163 -0
- ctrlcode/analysis/bug_detector.py +149 -0
- ctrlcode/analysis/code_graphs.py +329 -0
- ctrlcode/analysis/semantic.py +205 -0
- ctrlcode/analysis/static.py +183 -0
- ctrlcode/analysis/synthesizer.py +281 -0
- ctrlcode/analysis/tests.py +189 -0
- ctrlcode/cleanup/__init__.py +16 -0
- ctrlcode/cleanup/auto_merge.py +350 -0
- ctrlcode/cleanup/doc_gardening.py +388 -0
- ctrlcode/cleanup/pr_automation.py +330 -0
- ctrlcode/cleanup/scheduler.py +356 -0
- ctrlcode/config.py +380 -0
- ctrlcode/embeddings/__init__.py +6 -0
- ctrlcode/embeddings/embedder.py +192 -0
- ctrlcode/embeddings/vector_store.py +213 -0
- ctrlcode/fuzzing/__init__.py +24 -0
- ctrlcode/fuzzing/analyzer.py +280 -0
- ctrlcode/fuzzing/budget.py +112 -0
- ctrlcode/fuzzing/context.py +665 -0
- ctrlcode/fuzzing/context_fuzzer.py +506 -0
- ctrlcode/fuzzing/derived_orchestrator.py +732 -0
- ctrlcode/fuzzing/oracle_adapter.py +135 -0
- ctrlcode/linters/__init__.py +11 -0
- ctrlcode/linters/hand_rolled_utils.py +221 -0
- ctrlcode/linters/yolo_parsing.py +217 -0
- ctrlcode/metrics/__init__.py +6 -0
- ctrlcode/metrics/dashboard.py +283 -0
- ctrlcode/metrics/tech_debt.py +663 -0
- ctrlcode/paths.py +68 -0
- ctrlcode/permissions.py +179 -0
- ctrlcode/providers/__init__.py +15 -0
- ctrlcode/providers/anthropic.py +138 -0
- ctrlcode/providers/base.py +77 -0
- ctrlcode/providers/openai.py +197 -0
- ctrlcode/providers/parallel.py +104 -0
- ctrlcode/server.py +871 -0
- ctrlcode/session/__init__.py +6 -0
- ctrlcode/session/baseline.py +57 -0
- ctrlcode/session/manager.py +967 -0
- ctrlcode/skills/__init__.py +10 -0
- ctrlcode/skills/builtin/commit.toml +29 -0
- ctrlcode/skills/builtin/docs.toml +25 -0
- ctrlcode/skills/builtin/refactor.toml +33 -0
- ctrlcode/skills/builtin/review.toml +28 -0
- ctrlcode/skills/builtin/test.toml +28 -0
- ctrlcode/skills/loader.py +111 -0
- ctrlcode/skills/registry.py +139 -0
- ctrlcode/storage/__init__.py +19 -0
- ctrlcode/storage/history_db.py +708 -0
- ctrlcode/tools/__init__.py +220 -0
- ctrlcode/tools/bash.py +112 -0
- ctrlcode/tools/browser.py +352 -0
- ctrlcode/tools/executor.py +153 -0
- ctrlcode/tools/explore.py +486 -0
- ctrlcode/tools/mcp.py +108 -0
- ctrlcode/tools/observability.py +561 -0
- ctrlcode/tools/registry.py +193 -0
- ctrlcode/tools/todo.py +291 -0
- ctrlcode/tools/update.py +266 -0
- ctrlcode/tools/webfetch.py +147 -0
- ctrlcode-0.1.0.dist-info/METADATA +93 -0
- ctrlcode-0.1.0.dist-info/RECORD +75 -0
- ctrlcode-0.1.0.dist-info/WHEEL +4 -0
- ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Automated PR creation for cleanup refactorings."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PRConfig:
|
|
15
|
+
"""Configuration for auto-PR creation."""
|
|
16
|
+
|
|
17
|
+
enabled: bool = False
|
|
18
|
+
max_files_per_pr: int = 5
|
|
19
|
+
require_tests_pass: bool = True
|
|
20
|
+
require_linter_pass: bool = True
|
|
21
|
+
auto_assign_reviewers: list[str] | None = None
|
|
22
|
+
base_branch: str = "main"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PRAutomation:
|
|
26
|
+
"""Automated PR creation for cleanup refactorings."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, workspace_root: Path, config: PRConfig | None = None):
|
|
29
|
+
"""
|
|
30
|
+
Initialize PR automation.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
workspace_root: Root directory of workspace
|
|
34
|
+
config: PR configuration
|
|
35
|
+
"""
|
|
36
|
+
self.workspace_root = Path(workspace_root)
|
|
37
|
+
self.config = config or PRConfig()
|
|
38
|
+
|
|
39
|
+
def create_cleanup_pr(
|
|
40
|
+
self,
|
|
41
|
+
scan_results: dict[str, Any],
|
|
42
|
+
fixes: list[dict[str, Any]] | None = None,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""
|
|
45
|
+
Create PR for cleanup refactoring.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
scan_results: Results from cleanup scan
|
|
49
|
+
fixes: Optional list of fixes to apply
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dict with PR info or error
|
|
53
|
+
"""
|
|
54
|
+
if not self.config.enabled:
|
|
55
|
+
return {"status": "skipped", "reason": "PR automation disabled"}
|
|
56
|
+
|
|
57
|
+
if not self._check_gh_cli():
|
|
58
|
+
return {"status": "error", "reason": "gh CLI not available"}
|
|
59
|
+
|
|
60
|
+
# Determine principle being fixed
|
|
61
|
+
principle = scan_results.get("scan_type", "unknown")
|
|
62
|
+
|
|
63
|
+
# Create branch name
|
|
64
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
65
|
+
branch_name = f"refactor/{principle}-{timestamp}"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Create and checkout new branch
|
|
69
|
+
self._run_git(["checkout", "-b", branch_name])
|
|
70
|
+
|
|
71
|
+
# Apply fixes if provided
|
|
72
|
+
files_changed = []
|
|
73
|
+
if fixes:
|
|
74
|
+
for fix in fixes[:self.config.max_files_per_pr]:
|
|
75
|
+
if self._apply_fix(fix):
|
|
76
|
+
files_changed.append(fix.get("file"))
|
|
77
|
+
|
|
78
|
+
if not files_changed:
|
|
79
|
+
# No changes made, cleanup branch
|
|
80
|
+
self._run_git(["checkout", self.config.base_branch])
|
|
81
|
+
self._run_git(["branch", "-D", branch_name])
|
|
82
|
+
return {"status": "skipped", "reason": "No changes to commit"}
|
|
83
|
+
|
|
84
|
+
# Run tests if required
|
|
85
|
+
if self.config.require_tests_pass:
|
|
86
|
+
if not self._run_tests():
|
|
87
|
+
self._cleanup_branch(branch_name)
|
|
88
|
+
return {"status": "failed", "reason": "Tests failed"}
|
|
89
|
+
|
|
90
|
+
# Run linter if required
|
|
91
|
+
if self.config.require_linter_pass:
|
|
92
|
+
if not self._run_linter():
|
|
93
|
+
self._cleanup_branch(branch_name)
|
|
94
|
+
return {"status": "failed", "reason": "Linter failed"}
|
|
95
|
+
|
|
96
|
+
# Commit changes
|
|
97
|
+
commit_msg = self._generate_commit_message(principle, files_changed)
|
|
98
|
+
self._run_git(["add"] + files_changed)
|
|
99
|
+
self._run_git(["commit", "-m", commit_msg])
|
|
100
|
+
|
|
101
|
+
# Push to remote
|
|
102
|
+
self._run_git(["push", "-u", "origin", branch_name])
|
|
103
|
+
|
|
104
|
+
# Create PR
|
|
105
|
+
pr_info = self._create_pr(principle, scan_results, files_changed, branch_name)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"status": "success",
|
|
109
|
+
"branch": branch_name,
|
|
110
|
+
"pr_url": pr_info.get("url"),
|
|
111
|
+
"pr_number": pr_info.get("number"),
|
|
112
|
+
"files_changed": files_changed,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Failed to create PR: {e}", exc_info=True)
|
|
117
|
+
self._cleanup_branch(branch_name)
|
|
118
|
+
return {"status": "error", "reason": str(e)}
|
|
119
|
+
|
|
120
|
+
def _check_gh_cli(self) -> bool:
|
|
121
|
+
"""Check if gh CLI is available."""
|
|
122
|
+
try:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["gh", "--version"],
|
|
125
|
+
capture_output=True,
|
|
126
|
+
timeout=5,
|
|
127
|
+
)
|
|
128
|
+
return result.returncode == 0
|
|
129
|
+
except Exception:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def _run_git(self, args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
133
|
+
"""Run git command."""
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
["git"] + args,
|
|
136
|
+
cwd=self.workspace_root,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=30,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if check and result.returncode != 0:
|
|
143
|
+
raise RuntimeError(f"Git command failed: {result.stderr}")
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
def _apply_fix(self, fix: dict[str, Any]) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Apply a single fix to file.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
fix: Fix specification with file, old_content, new_content
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if fix was applied
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
file_path = self.workspace_root / fix["file"]
|
|
159
|
+
|
|
160
|
+
if not file_path.exists():
|
|
161
|
+
logger.warning(f"File not found: {file_path}")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
content = file_path.read_text()
|
|
165
|
+
|
|
166
|
+
# Simple replacement for now
|
|
167
|
+
old = fix.get("old_content")
|
|
168
|
+
new = fix.get("new_content")
|
|
169
|
+
|
|
170
|
+
if old and old in content:
|
|
171
|
+
updated = content.replace(old, new, 1)
|
|
172
|
+
file_path.write_text(updated)
|
|
173
|
+
logger.info(f"Applied fix to {file_path}")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Failed to apply fix: {e}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _run_tests(self) -> bool:
|
|
183
|
+
"""Run tests to verify changes."""
|
|
184
|
+
try:
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
["pytest", "-x"], # Stop on first failure
|
|
187
|
+
cwd=self.workspace_root,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
timeout=300,
|
|
190
|
+
)
|
|
191
|
+
return result.returncode == 0
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error(f"Tests failed: {e}")
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def _run_linter(self) -> bool:
|
|
197
|
+
"""Run linter to verify code quality."""
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
["ruff", "check"],
|
|
201
|
+
cwd=self.workspace_root,
|
|
202
|
+
capture_output=True,
|
|
203
|
+
timeout=60,
|
|
204
|
+
)
|
|
205
|
+
return result.returncode == 0
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Linter failed: {e}")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _generate_commit_message(self, principle: str, files_changed: list[str]) -> str:
|
|
211
|
+
"""Generate commit message."""
|
|
212
|
+
file_list = ", ".join(Path(f).name for f in files_changed[:3])
|
|
213
|
+
if len(files_changed) > 3:
|
|
214
|
+
file_list += f", +{len(files_changed) - 3} more"
|
|
215
|
+
|
|
216
|
+
return f"""refactor: fix {principle} violations
|
|
217
|
+
|
|
218
|
+
Automated cleanup of {principle} violations in {file_list}.
|
|
219
|
+
|
|
220
|
+
Co-Authored-By: Cleanup Agent <noreply@ctrlcode.dev>"""
|
|
221
|
+
|
|
222
|
+
def _create_pr(
|
|
223
|
+
self,
|
|
224
|
+
principle: str,
|
|
225
|
+
scan_results: dict[str, Any],
|
|
226
|
+
files_changed: list[str],
|
|
227
|
+
branch_name: str,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
"""Create GitHub PR using gh CLI."""
|
|
230
|
+
title = f"Refactor: Fix {principle} violations"
|
|
231
|
+
|
|
232
|
+
body = self._generate_pr_body(principle, scan_results, files_changed)
|
|
233
|
+
|
|
234
|
+
# Build gh pr create command
|
|
235
|
+
cmd = [
|
|
236
|
+
"gh",
|
|
237
|
+
"pr",
|
|
238
|
+
"create",
|
|
239
|
+
"--title",
|
|
240
|
+
title,
|
|
241
|
+
"--body",
|
|
242
|
+
body,
|
|
243
|
+
"--base",
|
|
244
|
+
self.config.base_branch,
|
|
245
|
+
"--head",
|
|
246
|
+
branch_name,
|
|
247
|
+
"--label",
|
|
248
|
+
"automated-cleanup",
|
|
249
|
+
"--label",
|
|
250
|
+
"auto-merge-candidate",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
# Add reviewers if configured
|
|
254
|
+
if self.config.auto_assign_reviewers:
|
|
255
|
+
for reviewer in self.config.auto_assign_reviewers:
|
|
256
|
+
cmd.extend(["--reviewer", reviewer])
|
|
257
|
+
|
|
258
|
+
result = subprocess.run(
|
|
259
|
+
cmd,
|
|
260
|
+
cwd=self.workspace_root,
|
|
261
|
+
capture_output=True,
|
|
262
|
+
text=True,
|
|
263
|
+
timeout=30,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if result.returncode != 0:
|
|
267
|
+
raise RuntimeError(f"Failed to create PR: {result.stderr}")
|
|
268
|
+
|
|
269
|
+
# Parse PR URL from output
|
|
270
|
+
pr_url = result.stdout.strip()
|
|
271
|
+
|
|
272
|
+
# Get PR number
|
|
273
|
+
pr_number = None
|
|
274
|
+
if pr_url:
|
|
275
|
+
pr_number = pr_url.split("/")[-1]
|
|
276
|
+
|
|
277
|
+
return {"url": pr_url, "number": pr_number}
|
|
278
|
+
|
|
279
|
+
def _generate_pr_body(
|
|
280
|
+
self, principle: str, scan_results: dict[str, Any], files_changed: list[str]
|
|
281
|
+
) -> str:
|
|
282
|
+
"""Generate PR body."""
|
|
283
|
+
violations_count = scan_results.get("total_violations", 0)
|
|
284
|
+
|
|
285
|
+
body = f"""## Summary
|
|
286
|
+
|
|
287
|
+
Automated cleanup of **{principle}** violations.
|
|
288
|
+
|
|
289
|
+
**Violations fixed:** {violations_count}
|
|
290
|
+
**Files changed:** {len(files_changed)}
|
|
291
|
+
|
|
292
|
+
## Changes
|
|
293
|
+
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
for file in files_changed:
|
|
297
|
+
body += f"- `{file}`\n"
|
|
298
|
+
|
|
299
|
+
body += f"""
|
|
300
|
+
|
|
301
|
+
## Principle
|
|
302
|
+
|
|
303
|
+
This PR addresses violations of the **{principle}** golden principle.
|
|
304
|
+
|
|
305
|
+
See: `docs/golden-principles/{principle}.md`
|
|
306
|
+
|
|
307
|
+
## Testing
|
|
308
|
+
|
|
309
|
+
- [x] All existing tests pass
|
|
310
|
+
- [x] Linter passes
|
|
311
|
+
|
|
312
|
+
## Notes
|
|
313
|
+
|
|
314
|
+
This PR was automatically generated by the Cleanup Agent. Changes are focused
|
|
315
|
+
and minimal to reduce review burden.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
🤖 Generated with Cleanup Agent
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
return body
|
|
323
|
+
|
|
324
|
+
def _cleanup_branch(self, branch_name: str):
|
|
325
|
+
"""Cleanup branch after failure."""
|
|
326
|
+
try:
|
|
327
|
+
self._run_git(["checkout", self.config.base_branch], check=False)
|
|
328
|
+
self._run_git(["branch", "-D", branch_name], check=False)
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Cleanup agent scheduler for automated code quality scans."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
10
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
11
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CleanupScheduler:
|
|
17
|
+
"""Scheduler for automated cleanup agent runs."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
workspace_root: Path,
|
|
22
|
+
results_dir: Path | None = None,
|
|
23
|
+
on_scan_complete: Callable[[dict[str, Any]], None] | None = None,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Initialize cleanup scheduler.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
workspace_root: Root directory of workspace
|
|
30
|
+
results_dir: Directory to store scan results (defaults to .ctrlcode/cleanup/results/)
|
|
31
|
+
on_scan_complete: Optional callback when scan completes
|
|
32
|
+
"""
|
|
33
|
+
self.workspace_root = Path(workspace_root)
|
|
34
|
+
self.results_dir = results_dir or (self.workspace_root / ".ctrlcode" / "cleanup" / "results")
|
|
35
|
+
self.results_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
self.on_scan_complete = on_scan_complete
|
|
38
|
+
self.scheduler = BackgroundScheduler()
|
|
39
|
+
self.is_running = False
|
|
40
|
+
|
|
41
|
+
def start(self):
|
|
42
|
+
"""Start the scheduler."""
|
|
43
|
+
if not self.is_running:
|
|
44
|
+
self.scheduler.start()
|
|
45
|
+
self.is_running = True
|
|
46
|
+
logger.info("Cleanup scheduler started")
|
|
47
|
+
|
|
48
|
+
def stop(self):
|
|
49
|
+
"""Stop the scheduler."""
|
|
50
|
+
if self.is_running:
|
|
51
|
+
self.scheduler.shutdown()
|
|
52
|
+
self.is_running = False
|
|
53
|
+
logger.info("Cleanup scheduler stopped")
|
|
54
|
+
|
|
55
|
+
def add_nightly_scan(self, scan_type: str, hour: int = 2, minute: int = 0):
|
|
56
|
+
"""
|
|
57
|
+
Add nightly scan job.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
scan_type: Type of scan to run
|
|
61
|
+
hour: Hour to run (0-23, default: 2am)
|
|
62
|
+
minute: Minute to run (0-59, default: 0)
|
|
63
|
+
"""
|
|
64
|
+
trigger = CronTrigger(hour=hour, minute=minute)
|
|
65
|
+
job_id = f"nightly_{scan_type}"
|
|
66
|
+
|
|
67
|
+
self.scheduler.add_job(
|
|
68
|
+
func=self._run_scan,
|
|
69
|
+
trigger=trigger,
|
|
70
|
+
args=[scan_type],
|
|
71
|
+
id=job_id,
|
|
72
|
+
name=f"Nightly {scan_type} scan",
|
|
73
|
+
replace_existing=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
logger.info(f"Scheduled nightly {scan_type} scan at {hour:02d}:{minute:02d}")
|
|
77
|
+
|
|
78
|
+
def add_weekly_scan(self, scan_type: str, day_of_week: int = 0, hour: int = 2, minute: int = 0):
|
|
79
|
+
"""
|
|
80
|
+
Add weekly scan job.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
scan_type: Type of scan to run
|
|
84
|
+
day_of_week: Day of week (0=Monday, 6=Sunday)
|
|
85
|
+
hour: Hour to run (0-23, default: 2am)
|
|
86
|
+
minute: Minute to run (0-59, default: 0)
|
|
87
|
+
"""
|
|
88
|
+
trigger = CronTrigger(day_of_week=day_of_week, hour=hour, minute=minute)
|
|
89
|
+
job_id = f"weekly_{scan_type}"
|
|
90
|
+
|
|
91
|
+
self.scheduler.add_job(
|
|
92
|
+
func=self._run_scan,
|
|
93
|
+
trigger=trigger,
|
|
94
|
+
args=[scan_type],
|
|
95
|
+
id=job_id,
|
|
96
|
+
name=f"Weekly {scan_type} scan",
|
|
97
|
+
replace_existing=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
logger.info(f"Scheduled weekly {scan_type} scan on day {day_of_week} at {hour:02d}:{minute:02d}")
|
|
101
|
+
|
|
102
|
+
def add_interval_scan(self, scan_type: str, hours: int = 0, minutes: int = 0):
|
|
103
|
+
"""
|
|
104
|
+
Add interval-based scan job.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
scan_type: Type of scan to run
|
|
108
|
+
hours: Interval in hours
|
|
109
|
+
minutes: Interval in minutes
|
|
110
|
+
"""
|
|
111
|
+
trigger = IntervalTrigger(hours=hours, minutes=minutes)
|
|
112
|
+
job_id = f"interval_{scan_type}"
|
|
113
|
+
|
|
114
|
+
self.scheduler.add_job(
|
|
115
|
+
func=self._run_scan,
|
|
116
|
+
trigger=trigger,
|
|
117
|
+
args=[scan_type],
|
|
118
|
+
id=job_id,
|
|
119
|
+
name=f"Interval {scan_type} scan",
|
|
120
|
+
replace_existing=True,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
logger.info(f"Scheduled interval {scan_type} scan every {hours}h {minutes}m")
|
|
124
|
+
|
|
125
|
+
def remove_scan(self, scan_type: str, schedule_type: str):
|
|
126
|
+
"""
|
|
127
|
+
Remove scheduled scan.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
scan_type: Type of scan
|
|
131
|
+
schedule_type: Schedule type (nightly, weekly, interval)
|
|
132
|
+
"""
|
|
133
|
+
job_id = f"{schedule_type}_{scan_type}"
|
|
134
|
+
try:
|
|
135
|
+
self.scheduler.remove_job(job_id)
|
|
136
|
+
logger.info(f"Removed {schedule_type} {scan_type} scan")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Failed to remove scan {job_id}: {e}")
|
|
139
|
+
|
|
140
|
+
def run_scan_now(self, scan_type: str) -> dict[str, Any]:
|
|
141
|
+
"""
|
|
142
|
+
Run scan immediately (synchronous).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
scan_type: Type of scan to run
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Scan results
|
|
149
|
+
"""
|
|
150
|
+
return self._run_scan(scan_type)
|
|
151
|
+
|
|
152
|
+
def _run_scan(self, scan_type: str) -> dict[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
Execute cleanup scan.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
scan_type: Type of scan to run
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Scan results
|
|
161
|
+
"""
|
|
162
|
+
logger.info(f"Running {scan_type} cleanup scan")
|
|
163
|
+
start_time = datetime.now()
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Run the appropriate scan
|
|
167
|
+
if scan_type == "golden_principles":
|
|
168
|
+
results = self._scan_golden_principles()
|
|
169
|
+
elif scan_type == "stale_docs":
|
|
170
|
+
results = self._scan_stale_docs()
|
|
171
|
+
elif scan_type == "code_smells":
|
|
172
|
+
results = self._scan_code_smells()
|
|
173
|
+
elif scan_type == "duplicates":
|
|
174
|
+
results = self._scan_duplicates()
|
|
175
|
+
else:
|
|
176
|
+
raise ValueError(f"Unknown scan type: {scan_type}")
|
|
177
|
+
|
|
178
|
+
# Add metadata
|
|
179
|
+
results["scan_type"] = scan_type
|
|
180
|
+
results["timestamp"] = start_time.isoformat()
|
|
181
|
+
results["duration_seconds"] = (datetime.now() - start_time).total_seconds()
|
|
182
|
+
results["status"] = "completed"
|
|
183
|
+
|
|
184
|
+
# Store results
|
|
185
|
+
self._store_results(scan_type, results)
|
|
186
|
+
|
|
187
|
+
# Notify callback
|
|
188
|
+
if self.on_scan_complete:
|
|
189
|
+
self.on_scan_complete(results)
|
|
190
|
+
|
|
191
|
+
logger.info(f"Completed {scan_type} scan in {results['duration_seconds']:.2f}s")
|
|
192
|
+
return results
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"Scan {scan_type} failed: {e}", exc_info=True)
|
|
196
|
+
error_results = {
|
|
197
|
+
"scan_type": scan_type,
|
|
198
|
+
"timestamp": start_time.isoformat(),
|
|
199
|
+
"status": "failed",
|
|
200
|
+
"error": str(e),
|
|
201
|
+
}
|
|
202
|
+
self._store_results(scan_type, error_results)
|
|
203
|
+
return error_results
|
|
204
|
+
|
|
205
|
+
def _scan_golden_principles(self) -> dict[str, Any]:
|
|
206
|
+
"""Scan for golden principles violations."""
|
|
207
|
+
from ..linters import lint_yolo_parsing, lint_hand_rolled_utils
|
|
208
|
+
|
|
209
|
+
violations = []
|
|
210
|
+
violation_counts = {}
|
|
211
|
+
|
|
212
|
+
# Scan all Python files
|
|
213
|
+
for py_file in self.workspace_root.rglob("*.py"):
|
|
214
|
+
# Skip test directories and virtual environments
|
|
215
|
+
path_parts = py_file.parts
|
|
216
|
+
if ".venv" in path_parts or "tests" in path_parts or "__pycache__" in path_parts:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Run linters
|
|
220
|
+
for linter_name, linter_func in [
|
|
221
|
+
("yolo_parsing", lint_yolo_parsing),
|
|
222
|
+
("hand_rolled_utils", lint_hand_rolled_utils),
|
|
223
|
+
]:
|
|
224
|
+
file_violations = linter_func(py_file)
|
|
225
|
+
for v in file_violations:
|
|
226
|
+
violations.append({
|
|
227
|
+
"file": v.file,
|
|
228
|
+
"line": v.line,
|
|
229
|
+
"column": v.column,
|
|
230
|
+
"message": v.message,
|
|
231
|
+
"severity": v.severity,
|
|
232
|
+
"principle": v.principle,
|
|
233
|
+
"linter": linter_name,
|
|
234
|
+
})
|
|
235
|
+
violation_counts[v.principle] = violation_counts.get(v.principle, 0) + 1
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"total_violations": len(violations),
|
|
239
|
+
"violations_by_principle": violation_counts,
|
|
240
|
+
"violations": violations[:100], # Limit to first 100 for storage
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def _scan_stale_docs(self) -> dict[str, Any]:
|
|
244
|
+
"""Scan for stale documentation."""
|
|
245
|
+
import subprocess
|
|
246
|
+
|
|
247
|
+
stale_docs = []
|
|
248
|
+
|
|
249
|
+
# Find markdown files
|
|
250
|
+
for md_file in self.workspace_root.rglob("*.md"):
|
|
251
|
+
if ".venv" in str(md_file):
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Get last modified time
|
|
255
|
+
try:
|
|
256
|
+
result = subprocess.run(
|
|
257
|
+
["git", "log", "-1", "--format=%ct", str(md_file)],
|
|
258
|
+
cwd=self.workspace_root,
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
timeout=5,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
265
|
+
last_modified = int(result.stdout.strip())
|
|
266
|
+
days_old = (datetime.now().timestamp() - last_modified) / 86400
|
|
267
|
+
|
|
268
|
+
# Flag as stale if > 90 days old
|
|
269
|
+
if days_old > 90:
|
|
270
|
+
stale_docs.append({
|
|
271
|
+
"file": str(md_file.relative_to(self.workspace_root)),
|
|
272
|
+
"days_old": int(days_old),
|
|
273
|
+
})
|
|
274
|
+
except Exception:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"total_stale": len(stale_docs),
|
|
279
|
+
"stale_docs": sorted(stale_docs, key=lambda x: x["days_old"], reverse=True),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def _scan_code_smells(self) -> dict[str, Any]:
|
|
283
|
+
"""Scan for code smells using static analysis."""
|
|
284
|
+
import subprocess
|
|
285
|
+
|
|
286
|
+
smells = {}
|
|
287
|
+
|
|
288
|
+
# Run pylint for complexity metrics
|
|
289
|
+
try:
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
["python", "-m", "pylint", "--disable=all", "--enable=R", str(self.workspace_root / "src")],
|
|
292
|
+
capture_output=True,
|
|
293
|
+
text=True,
|
|
294
|
+
timeout=60,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Parse output for complexity warnings
|
|
298
|
+
for line in result.stdout.split("\n"):
|
|
299
|
+
if "too-many" in line.lower():
|
|
300
|
+
smells["complexity"] = smells.get("complexity", 0) + 1
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
return {"total_smells": sum(smells.values()), "smells_by_type": smells}
|
|
305
|
+
|
|
306
|
+
def _scan_duplicates(self) -> dict[str, Any]:
|
|
307
|
+
"""Scan for duplicate code patterns."""
|
|
308
|
+
# Placeholder - would integrate with tools like jscpd or similar
|
|
309
|
+
return {"total_duplicates": 0, "duplicate_blocks": []}
|
|
310
|
+
|
|
311
|
+
def _store_results(self, scan_type: str, results: dict[str, Any]):
|
|
312
|
+
"""Store scan results to file."""
|
|
313
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
314
|
+
filename = f"{scan_type}_{timestamp}.json"
|
|
315
|
+
filepath = self.results_dir / filename
|
|
316
|
+
|
|
317
|
+
with open(filepath, "w") as f:
|
|
318
|
+
json.dump(results, f, indent=2)
|
|
319
|
+
|
|
320
|
+
logger.info(f"Stored scan results: {filepath}")
|
|
321
|
+
|
|
322
|
+
def get_latest_results(self, scan_type: str) -> dict[str, Any] | None:
|
|
323
|
+
"""
|
|
324
|
+
Get latest results for scan type.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
scan_type: Type of scan
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Latest scan results or None
|
|
331
|
+
"""
|
|
332
|
+
pattern = f"{scan_type}_*.json"
|
|
333
|
+
result_files = sorted(self.results_dir.glob(pattern), reverse=True)
|
|
334
|
+
|
|
335
|
+
if result_files:
|
|
336
|
+
with open(result_files[0]) as f:
|
|
337
|
+
return json.load(f)
|
|
338
|
+
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def get_scheduled_jobs(self) -> list[dict[str, Any]]:
|
|
342
|
+
"""
|
|
343
|
+
Get list of scheduled jobs.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
List of job info dicts
|
|
347
|
+
"""
|
|
348
|
+
jobs = []
|
|
349
|
+
for job in self.scheduler.get_jobs():
|
|
350
|
+
jobs.append({
|
|
351
|
+
"id": job.id,
|
|
352
|
+
"name": job.name,
|
|
353
|
+
"next_run": job.next_run_time.isoformat() if job.next_run_time else None,
|
|
354
|
+
"trigger": str(job.trigger),
|
|
355
|
+
})
|
|
356
|
+
return jobs
|