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.
Files changed (75) hide show
  1. ctrlcode/__init__.py +8 -0
  2. ctrlcode/agents/__init__.py +29 -0
  3. ctrlcode/agents/cleanup.py +388 -0
  4. ctrlcode/agents/communication.py +439 -0
  5. ctrlcode/agents/observability.py +421 -0
  6. ctrlcode/agents/react_loop.py +297 -0
  7. ctrlcode/agents/registry.py +211 -0
  8. ctrlcode/agents/result_parser.py +242 -0
  9. ctrlcode/agents/workflow.py +723 -0
  10. ctrlcode/analysis/__init__.py +28 -0
  11. ctrlcode/analysis/ast_diff.py +163 -0
  12. ctrlcode/analysis/bug_detector.py +149 -0
  13. ctrlcode/analysis/code_graphs.py +329 -0
  14. ctrlcode/analysis/semantic.py +205 -0
  15. ctrlcode/analysis/static.py +183 -0
  16. ctrlcode/analysis/synthesizer.py +281 -0
  17. ctrlcode/analysis/tests.py +189 -0
  18. ctrlcode/cleanup/__init__.py +16 -0
  19. ctrlcode/cleanup/auto_merge.py +350 -0
  20. ctrlcode/cleanup/doc_gardening.py +388 -0
  21. ctrlcode/cleanup/pr_automation.py +330 -0
  22. ctrlcode/cleanup/scheduler.py +356 -0
  23. ctrlcode/config.py +380 -0
  24. ctrlcode/embeddings/__init__.py +6 -0
  25. ctrlcode/embeddings/embedder.py +192 -0
  26. ctrlcode/embeddings/vector_store.py +213 -0
  27. ctrlcode/fuzzing/__init__.py +24 -0
  28. ctrlcode/fuzzing/analyzer.py +280 -0
  29. ctrlcode/fuzzing/budget.py +112 -0
  30. ctrlcode/fuzzing/context.py +665 -0
  31. ctrlcode/fuzzing/context_fuzzer.py +506 -0
  32. ctrlcode/fuzzing/derived_orchestrator.py +732 -0
  33. ctrlcode/fuzzing/oracle_adapter.py +135 -0
  34. ctrlcode/linters/__init__.py +11 -0
  35. ctrlcode/linters/hand_rolled_utils.py +221 -0
  36. ctrlcode/linters/yolo_parsing.py +217 -0
  37. ctrlcode/metrics/__init__.py +6 -0
  38. ctrlcode/metrics/dashboard.py +283 -0
  39. ctrlcode/metrics/tech_debt.py +663 -0
  40. ctrlcode/paths.py +68 -0
  41. ctrlcode/permissions.py +179 -0
  42. ctrlcode/providers/__init__.py +15 -0
  43. ctrlcode/providers/anthropic.py +138 -0
  44. ctrlcode/providers/base.py +77 -0
  45. ctrlcode/providers/openai.py +197 -0
  46. ctrlcode/providers/parallel.py +104 -0
  47. ctrlcode/server.py +871 -0
  48. ctrlcode/session/__init__.py +6 -0
  49. ctrlcode/session/baseline.py +57 -0
  50. ctrlcode/session/manager.py +967 -0
  51. ctrlcode/skills/__init__.py +10 -0
  52. ctrlcode/skills/builtin/commit.toml +29 -0
  53. ctrlcode/skills/builtin/docs.toml +25 -0
  54. ctrlcode/skills/builtin/refactor.toml +33 -0
  55. ctrlcode/skills/builtin/review.toml +28 -0
  56. ctrlcode/skills/builtin/test.toml +28 -0
  57. ctrlcode/skills/loader.py +111 -0
  58. ctrlcode/skills/registry.py +139 -0
  59. ctrlcode/storage/__init__.py +19 -0
  60. ctrlcode/storage/history_db.py +708 -0
  61. ctrlcode/tools/__init__.py +220 -0
  62. ctrlcode/tools/bash.py +112 -0
  63. ctrlcode/tools/browser.py +352 -0
  64. ctrlcode/tools/executor.py +153 -0
  65. ctrlcode/tools/explore.py +486 -0
  66. ctrlcode/tools/mcp.py +108 -0
  67. ctrlcode/tools/observability.py +561 -0
  68. ctrlcode/tools/registry.py +193 -0
  69. ctrlcode/tools/todo.py +291 -0
  70. ctrlcode/tools/update.py +266 -0
  71. ctrlcode/tools/webfetch.py +147 -0
  72. ctrlcode-0.1.0.dist-info/METADATA +93 -0
  73. ctrlcode-0.1.0.dist-info/RECORD +75 -0
  74. ctrlcode-0.1.0.dist-info/WHEEL +4 -0
  75. 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