codexa 0.4.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 (189) hide show
  1. codexa-0.4.0.dist-info/METADATA +650 -0
  2. codexa-0.4.0.dist-info/RECORD +189 -0
  3. codexa-0.4.0.dist-info/WHEEL +5 -0
  4. codexa-0.4.0.dist-info/entry_points.txt +2 -0
  5. codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. codexa-0.4.0.dist-info/top_level.txt +1 -0
  7. semantic_code_intelligence/__init__.py +5 -0
  8. semantic_code_intelligence/analysis/__init__.py +21 -0
  9. semantic_code_intelligence/analysis/ai_features.py +351 -0
  10. semantic_code_intelligence/bridge/__init__.py +28 -0
  11. semantic_code_intelligence/bridge/context_provider.py +245 -0
  12. semantic_code_intelligence/bridge/protocol.py +167 -0
  13. semantic_code_intelligence/bridge/server.py +348 -0
  14. semantic_code_intelligence/bridge/vscode.py +271 -0
  15. semantic_code_intelligence/ci/__init__.py +13 -0
  16. semantic_code_intelligence/ci/hooks.py +98 -0
  17. semantic_code_intelligence/ci/hotspots.py +272 -0
  18. semantic_code_intelligence/ci/impact.py +246 -0
  19. semantic_code_intelligence/ci/metrics.py +591 -0
  20. semantic_code_intelligence/ci/pr.py +412 -0
  21. semantic_code_intelligence/ci/quality.py +557 -0
  22. semantic_code_intelligence/ci/templates.py +164 -0
  23. semantic_code_intelligence/ci/trace.py +224 -0
  24. semantic_code_intelligence/cli/__init__.py +0 -0
  25. semantic_code_intelligence/cli/commands/__init__.py +0 -0
  26. semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
  27. semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
  28. semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
  29. semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
  30. semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
  31. semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
  32. semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
  33. semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
  34. semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
  35. semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
  36. semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
  37. semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
  38. semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
  39. semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
  40. semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
  41. semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
  42. semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
  43. semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
  44. semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
  45. semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
  46. semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
  47. semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
  48. semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
  49. semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
  50. semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
  51. semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
  52. semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
  53. semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
  54. semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
  55. semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
  56. semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
  57. semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
  58. semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
  59. semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
  60. semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
  61. semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
  62. semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
  63. semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
  64. semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
  65. semantic_code_intelligence/cli/main.py +65 -0
  66. semantic_code_intelligence/cli/router.py +92 -0
  67. semantic_code_intelligence/config/__init__.py +0 -0
  68. semantic_code_intelligence/config/settings.py +260 -0
  69. semantic_code_intelligence/context/__init__.py +19 -0
  70. semantic_code_intelligence/context/engine.py +429 -0
  71. semantic_code_intelligence/context/memory.py +253 -0
  72. semantic_code_intelligence/daemon/__init__.py +1 -0
  73. semantic_code_intelligence/daemon/watcher.py +515 -0
  74. semantic_code_intelligence/docs/__init__.py +1080 -0
  75. semantic_code_intelligence/embeddings/__init__.py +0 -0
  76. semantic_code_intelligence/embeddings/enhanced.py +131 -0
  77. semantic_code_intelligence/embeddings/generator.py +149 -0
  78. semantic_code_intelligence/embeddings/model_registry.py +100 -0
  79. semantic_code_intelligence/evolution/__init__.py +1 -0
  80. semantic_code_intelligence/evolution/budget_guard.py +111 -0
  81. semantic_code_intelligence/evolution/commit_manager.py +88 -0
  82. semantic_code_intelligence/evolution/context_builder.py +131 -0
  83. semantic_code_intelligence/evolution/engine.py +249 -0
  84. semantic_code_intelligence/evolution/patch_generator.py +229 -0
  85. semantic_code_intelligence/evolution/task_selector.py +214 -0
  86. semantic_code_intelligence/evolution/test_runner.py +111 -0
  87. semantic_code_intelligence/indexing/__init__.py +0 -0
  88. semantic_code_intelligence/indexing/chunker.py +174 -0
  89. semantic_code_intelligence/indexing/parallel.py +86 -0
  90. semantic_code_intelligence/indexing/scanner.py +146 -0
  91. semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
  92. semantic_code_intelligence/llm/__init__.py +62 -0
  93. semantic_code_intelligence/llm/cache.py +219 -0
  94. semantic_code_intelligence/llm/cached_provider.py +145 -0
  95. semantic_code_intelligence/llm/conversation.py +190 -0
  96. semantic_code_intelligence/llm/cross_refactor.py +272 -0
  97. semantic_code_intelligence/llm/investigation.py +274 -0
  98. semantic_code_intelligence/llm/mock_provider.py +77 -0
  99. semantic_code_intelligence/llm/ollama_provider.py +122 -0
  100. semantic_code_intelligence/llm/openai_provider.py +100 -0
  101. semantic_code_intelligence/llm/provider.py +92 -0
  102. semantic_code_intelligence/llm/rate_limiter.py +164 -0
  103. semantic_code_intelligence/llm/reasoning.py +438 -0
  104. semantic_code_intelligence/llm/safety.py +110 -0
  105. semantic_code_intelligence/llm/streaming.py +251 -0
  106. semantic_code_intelligence/lsp/__init__.py +609 -0
  107. semantic_code_intelligence/mcp/__init__.py +393 -0
  108. semantic_code_intelligence/parsing/__init__.py +19 -0
  109. semantic_code_intelligence/parsing/parser.py +375 -0
  110. semantic_code_intelligence/plugins/__init__.py +255 -0
  111. semantic_code_intelligence/plugins/examples/__init__.py +1 -0
  112. semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
  113. semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
  114. semantic_code_intelligence/scalability/__init__.py +205 -0
  115. semantic_code_intelligence/search/__init__.py +0 -0
  116. semantic_code_intelligence/search/formatter.py +123 -0
  117. semantic_code_intelligence/search/grep.py +361 -0
  118. semantic_code_intelligence/search/hybrid_search.py +170 -0
  119. semantic_code_intelligence/search/keyword_search.py +311 -0
  120. semantic_code_intelligence/search/section_expander.py +103 -0
  121. semantic_code_intelligence/services/__init__.py +0 -0
  122. semantic_code_intelligence/services/indexing_service.py +630 -0
  123. semantic_code_intelligence/services/search_service.py +269 -0
  124. semantic_code_intelligence/storage/__init__.py +0 -0
  125. semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
  126. semantic_code_intelligence/storage/hash_store.py +66 -0
  127. semantic_code_intelligence/storage/index_manifest.py +85 -0
  128. semantic_code_intelligence/storage/index_stats.py +138 -0
  129. semantic_code_intelligence/storage/query_history.py +160 -0
  130. semantic_code_intelligence/storage/symbol_registry.py +209 -0
  131. semantic_code_intelligence/storage/vector_store.py +297 -0
  132. semantic_code_intelligence/tests/__init__.py +0 -0
  133. semantic_code_intelligence/tests/test_ai_features.py +351 -0
  134. semantic_code_intelligence/tests/test_chunker.py +119 -0
  135. semantic_code_intelligence/tests/test_cli.py +188 -0
  136. semantic_code_intelligence/tests/test_config.py +154 -0
  137. semantic_code_intelligence/tests/test_context.py +381 -0
  138. semantic_code_intelligence/tests/test_embeddings.py +73 -0
  139. semantic_code_intelligence/tests/test_endtoend.py +1142 -0
  140. semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
  141. semantic_code_intelligence/tests/test_hash_store.py +79 -0
  142. semantic_code_intelligence/tests/test_logging.py +55 -0
  143. semantic_code_intelligence/tests/test_new_cli.py +138 -0
  144. semantic_code_intelligence/tests/test_parser.py +495 -0
  145. semantic_code_intelligence/tests/test_phase10.py +355 -0
  146. semantic_code_intelligence/tests/test_phase11.py +593 -0
  147. semantic_code_intelligence/tests/test_phase12.py +375 -0
  148. semantic_code_intelligence/tests/test_phase13.py +663 -0
  149. semantic_code_intelligence/tests/test_phase14.py +568 -0
  150. semantic_code_intelligence/tests/test_phase15.py +814 -0
  151. semantic_code_intelligence/tests/test_phase16.py +792 -0
  152. semantic_code_intelligence/tests/test_phase17.py +815 -0
  153. semantic_code_intelligence/tests/test_phase18.py +934 -0
  154. semantic_code_intelligence/tests/test_phase19.py +986 -0
  155. semantic_code_intelligence/tests/test_phase20.py +2753 -0
  156. semantic_code_intelligence/tests/test_phase20b.py +2058 -0
  157. semantic_code_intelligence/tests/test_phase20c.py +962 -0
  158. semantic_code_intelligence/tests/test_phase21.py +428 -0
  159. semantic_code_intelligence/tests/test_phase22.py +799 -0
  160. semantic_code_intelligence/tests/test_phase23.py +783 -0
  161. semantic_code_intelligence/tests/test_phase24.py +715 -0
  162. semantic_code_intelligence/tests/test_phase25.py +496 -0
  163. semantic_code_intelligence/tests/test_phase26.py +251 -0
  164. semantic_code_intelligence/tests/test_phase27.py +531 -0
  165. semantic_code_intelligence/tests/test_phase8.py +592 -0
  166. semantic_code_intelligence/tests/test_phase9.py +643 -0
  167. semantic_code_intelligence/tests/test_plugins.py +293 -0
  168. semantic_code_intelligence/tests/test_priority_features.py +727 -0
  169. semantic_code_intelligence/tests/test_router.py +41 -0
  170. semantic_code_intelligence/tests/test_scalability.py +138 -0
  171. semantic_code_intelligence/tests/test_scanner.py +125 -0
  172. semantic_code_intelligence/tests/test_search.py +160 -0
  173. semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
  174. semantic_code_intelligence/tests/test_tools.py +182 -0
  175. semantic_code_intelligence/tests/test_vector_store.py +151 -0
  176. semantic_code_intelligence/tests/test_watcher.py +211 -0
  177. semantic_code_intelligence/tools/__init__.py +442 -0
  178. semantic_code_intelligence/tools/executor.py +232 -0
  179. semantic_code_intelligence/tools/protocol.py +200 -0
  180. semantic_code_intelligence/tui/__init__.py +454 -0
  181. semantic_code_intelligence/utils/__init__.py +0 -0
  182. semantic_code_intelligence/utils/logging.py +112 -0
  183. semantic_code_intelligence/version.py +3 -0
  184. semantic_code_intelligence/web/__init__.py +11 -0
  185. semantic_code_intelligence/web/api.py +289 -0
  186. semantic_code_intelligence/web/server.py +397 -0
  187. semantic_code_intelligence/web/ui.py +659 -0
  188. semantic_code_intelligence/web/visualize.py +226 -0
  189. semantic_code_intelligence/workspace/__init__.py +427 -0
@@ -0,0 +1,591 @@
1
+ """Code quality metrics, trend tracking, and policy enforcement.
2
+
3
+ Provides:
4
+ - **Maintainability index** — weighted composite of complexity, comment ratio,
5
+ LOC, and dead-code density for individual files and whole projects.
6
+ - **QualitySnapshot** — timestamped metric capture persisted via WorkspaceMemory.
7
+ - **Trend analysis** — linear-regression slope over historical snapshots for
8
+ detecting improvement or degradation.
9
+ - **QualityPolicy / QualityGate** — configurable thresholds and CI-friendly
10
+ gate enforcement.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import re
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from semantic_code_intelligence.ci.quality import (
24
+ QualityReport,
25
+ analyze_complexity,
26
+ analyze_project,
27
+ compute_complexity,
28
+ )
29
+ from semantic_code_intelligence.context.memory import WorkspaceMemory
30
+ from semantic_code_intelligence.utils.logging import get_logger
31
+
32
+ logger = get_logger("ci.metrics")
33
+
34
+ # ── Maintainability index ────────────────────────────────────────────
35
+
36
+ # The index is inspired by the Software Engineering Institute formula:
37
+ # MI = 171 - 5.2·ln(avgV) - 0.23·avgCC - 16.2·ln(avgLOC) + 50·sin(sqrt(2.4·%comments))
38
+ # We simplify and clamp to [0, 100] for usability.
39
+
40
+
41
+ @dataclass
42
+ class FileMetrics:
43
+ """Per-file quality metrics."""
44
+
45
+ file_path: str
46
+ lines_of_code: int = 0
47
+ comment_lines: int = 0
48
+ blank_lines: int = 0
49
+ avg_complexity: float = 0.0
50
+ max_complexity: int = 0
51
+ symbol_count: int = 0
52
+ maintainability_index: float = 100.0
53
+
54
+ @property
55
+ def comment_ratio(self) -> float:
56
+ """Fraction of lines that are comments (0.0–1.0)."""
57
+ total = self.lines_of_code + self.comment_lines + self.blank_lines
58
+ if total == 0:
59
+ return 0.0
60
+ return self.comment_lines / total
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """Serialise file-level metrics to a plain dictionary."""
64
+ return {
65
+ "file_path": self.file_path,
66
+ "lines_of_code": self.lines_of_code,
67
+ "comment_lines": self.comment_lines,
68
+ "blank_lines": self.blank_lines,
69
+ "avg_complexity": round(self.avg_complexity, 2),
70
+ "max_complexity": self.max_complexity,
71
+ "symbol_count": self.symbol_count,
72
+ "maintainability_index": round(self.maintainability_index, 1),
73
+ "comment_ratio": round(self.comment_ratio, 3),
74
+ }
75
+
76
+
77
+ @dataclass
78
+ class ProjectMetrics:
79
+ """Project-wide quality metrics aggregation."""
80
+
81
+ files_analyzed: int = 0
82
+ total_loc: int = 0
83
+ total_comment_lines: int = 0
84
+ total_blank_lines: int = 0
85
+ avg_complexity: float = 0.0
86
+ max_complexity: int = 0
87
+ total_symbols: int = 0
88
+ maintainability_index: float = 100.0
89
+ file_metrics: list[FileMetrics] = field(default_factory=list)
90
+
91
+ @property
92
+ def comment_ratio(self) -> float:
93
+ """Fraction of lines that are comments (0.0–1.0)."""
94
+ total = self.total_loc + self.total_comment_lines + self.total_blank_lines
95
+ if total == 0:
96
+ return 0.0
97
+ return self.total_comment_lines / total
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ """Serialise project-wide metrics to a plain dictionary."""
101
+ return {
102
+ "files_analyzed": self.files_analyzed,
103
+ "total_loc": self.total_loc,
104
+ "total_comment_lines": self.total_comment_lines,
105
+ "total_blank_lines": self.total_blank_lines,
106
+ "avg_complexity": round(self.avg_complexity, 2),
107
+ "max_complexity": self.max_complexity,
108
+ "total_symbols": self.total_symbols,
109
+ "maintainability_index": round(self.maintainability_index, 1),
110
+ "comment_ratio": round(self.comment_ratio, 3),
111
+ "file_metrics": [f.to_dict() for f in self.file_metrics],
112
+ }
113
+
114
+
115
+ # Comment line patterns for common languages
116
+ _COMMENT_PATTERNS: list[re.Pattern[str]] = [
117
+ re.compile(r"^\s*#"), # Python, Ruby, Bash
118
+ re.compile(r"^\s*//"), # JS, TS, Java, Go, Rust, C++, C#
119
+ re.compile(r"^\s*/\*"), # Block comments start
120
+ re.compile(r"^\s*\*"), # Inside block comment
121
+ re.compile(r"^\s*\*/"), # Block comment end
122
+ ]
123
+
124
+
125
+ def _count_lines(content: str) -> tuple[int, int, int]:
126
+ """Return (code_lines, comment_lines, blank_lines) for source content."""
127
+ code = 0
128
+ comments = 0
129
+ blanks = 0
130
+ for line in content.splitlines():
131
+ stripped = line.strip()
132
+ if not stripped:
133
+ blanks += 1
134
+ elif any(p.match(line) for p in _COMMENT_PATTERNS):
135
+ comments += 1
136
+ else:
137
+ code += 1
138
+ return code, comments, blanks
139
+
140
+
141
+ def _compute_mi(avg_loc: float, avg_cc: float, comment_ratio: float) -> float:
142
+ """Compute maintainability index (0-100 scale).
143
+
144
+ Simplified SEI formula, clamped to [0, 100].
145
+ """
146
+ ln_loc = math.log(max(avg_loc, 1))
147
+ ln_vol = math.log(max(avg_loc * max(avg_cc, 1), 1))
148
+ sin_cm = math.sin(math.sqrt(2.4 * comment_ratio))
149
+ raw = 171.0 - 5.2 * ln_vol - 0.23 * avg_cc - 16.2 * ln_loc + 50.0 * sin_cm
150
+ # Normalise to 0-100
151
+ return max(0.0, min(100.0, raw * 100.0 / 171.0))
152
+
153
+
154
+ def compute_file_metrics(file_path: str | Path) -> FileMetrics:
155
+ """Compute quality metrics for a single file."""
156
+ from semantic_code_intelligence.parsing.parser import parse_file
157
+
158
+ fpath = Path(file_path)
159
+ try:
160
+ content = fpath.read_text(encoding="utf-8", errors="replace")
161
+ except Exception:
162
+ return FileMetrics(file_path=str(fpath))
163
+
164
+ loc, comments, blanks = _count_lines(content)
165
+ symbols = parse_file(str(fpath))
166
+
167
+ complexities = [compute_complexity(s) for s in symbols if s.kind in ("function", "method")]
168
+ avg_cc = sum(c.complexity for c in complexities) / max(len(complexities), 1)
169
+ max_cc = max((c.complexity for c in complexities), default=0)
170
+
171
+ total = loc + comments + blanks
172
+ cr = comments / total if total > 0 else 0.0
173
+ mi = _compute_mi(float(loc), avg_cc, cr)
174
+
175
+ return FileMetrics(
176
+ file_path=str(fpath),
177
+ lines_of_code=loc,
178
+ comment_lines=comments,
179
+ blank_lines=blanks,
180
+ avg_complexity=avg_cc,
181
+ max_complexity=max_cc,
182
+ symbol_count=len(symbols),
183
+ maintainability_index=mi,
184
+ )
185
+
186
+
187
+ def compute_project_metrics(
188
+ project_root: Path,
189
+ *,
190
+ file_paths: list[str] | None = None,
191
+ ) -> ProjectMetrics:
192
+ """Compute aggregated quality metrics across a project."""
193
+ from semantic_code_intelligence.parsing.parser import EXTENSION_TO_LANGUAGE
194
+
195
+ root = project_root.resolve()
196
+
197
+ if file_paths:
198
+ files = [str(Path(f).resolve()) for f in file_paths]
199
+ else:
200
+ files = []
201
+ for f in root.rglob("*"):
202
+ if f.is_file() and f.suffix in EXTENSION_TO_LANGUAGE:
203
+ parts = f.relative_to(root).parts
204
+ if any(
205
+ p.startswith(".") or p in ("__pycache__", "node_modules", ".codexa")
206
+ for p in parts
207
+ ):
208
+ continue
209
+ files.append(str(f))
210
+
211
+ file_metrics: list[FileMetrics] = []
212
+ for fpath in files:
213
+ try:
214
+ fm = compute_file_metrics(fpath)
215
+ file_metrics.append(fm)
216
+ except Exception as exc:
217
+ logger.debug("Skipping %s: %s", fpath, exc)
218
+
219
+ total_loc = sum(f.lines_of_code for f in file_metrics)
220
+ total_cm = sum(f.comment_lines for f in file_metrics)
221
+ total_bl = sum(f.blank_lines for f in file_metrics)
222
+ total_symbols = sum(f.symbol_count for f in file_metrics)
223
+
224
+ if file_metrics:
225
+ avg_cc = sum(f.avg_complexity for f in file_metrics) / len(file_metrics)
226
+ else:
227
+ avg_cc = 0.0
228
+ max_cc = max((f.max_complexity for f in file_metrics), default=0)
229
+
230
+ total = total_loc + total_cm + total_bl
231
+ cr = total_cm / total if total > 0 else 0.0
232
+ mi = _compute_mi(float(total_loc) / max(len(file_metrics), 1), avg_cc, cr)
233
+
234
+ return ProjectMetrics(
235
+ files_analyzed=len(file_metrics),
236
+ total_loc=total_loc,
237
+ total_comment_lines=total_cm,
238
+ total_blank_lines=total_bl,
239
+ avg_complexity=avg_cc,
240
+ max_complexity=max_cc,
241
+ total_symbols=total_symbols,
242
+ maintainability_index=mi,
243
+ file_metrics=file_metrics,
244
+ )
245
+
246
+
247
+ # ── Metric snapshots & trend tracking ────────────────────────────────
248
+
249
+ _SNAPSHOT_PREFIX = "quality:snapshot:"
250
+
251
+
252
+ @dataclass
253
+ class QualitySnapshot:
254
+ """Timestamped capture of quality metrics."""
255
+
256
+ timestamp: float
257
+ maintainability_index: float
258
+ total_loc: int
259
+ total_symbols: int
260
+ issue_count: int
261
+ files_analyzed: int
262
+ avg_complexity: float
263
+ comment_ratio: float
264
+ metadata: dict[str, Any] = field(default_factory=dict)
265
+
266
+ def to_dict(self) -> dict[str, Any]:
267
+ """Serialise the snapshot to a plain dictionary."""
268
+ return {
269
+ "timestamp": self.timestamp,
270
+ "maintainability_index": round(self.maintainability_index, 1),
271
+ "total_loc": self.total_loc,
272
+ "total_symbols": self.total_symbols,
273
+ "issue_count": self.issue_count,
274
+ "files_analyzed": self.files_analyzed,
275
+ "avg_complexity": round(self.avg_complexity, 2),
276
+ "comment_ratio": round(self.comment_ratio, 3),
277
+ "metadata": self.metadata,
278
+ }
279
+
280
+ @staticmethod
281
+ def from_dict(data: dict[str, Any]) -> QualitySnapshot:
282
+ """Construct a :class:`QualitySnapshot` from a dictionary."""
283
+ return QualitySnapshot(
284
+ timestamp=data["timestamp"],
285
+ maintainability_index=data["maintainability_index"],
286
+ total_loc=data["total_loc"],
287
+ total_symbols=data["total_symbols"],
288
+ issue_count=data["issue_count"],
289
+ files_analyzed=data["files_analyzed"],
290
+ avg_complexity=data.get("avg_complexity", 0.0),
291
+ comment_ratio=data.get("comment_ratio", 0.0),
292
+ metadata=data.get("metadata", {}),
293
+ )
294
+
295
+
296
+ def save_snapshot(
297
+ project_root: Path,
298
+ project_metrics: ProjectMetrics,
299
+ quality_report: QualityReport,
300
+ *,
301
+ metadata: dict[str, Any] | None = None,
302
+ ) -> QualitySnapshot:
303
+ """Persist a quality snapshot via WorkspaceMemory."""
304
+ ts = time.time()
305
+ snapshot = QualitySnapshot(
306
+ timestamp=ts,
307
+ maintainability_index=project_metrics.maintainability_index,
308
+ total_loc=project_metrics.total_loc,
309
+ total_symbols=project_metrics.total_symbols,
310
+ issue_count=quality_report.issue_count,
311
+ files_analyzed=project_metrics.files_analyzed,
312
+ avg_complexity=project_metrics.avg_complexity,
313
+ comment_ratio=project_metrics.comment_ratio,
314
+ metadata=metadata or {},
315
+ )
316
+
317
+ mem = WorkspaceMemory(project_root)
318
+ key = f"{_SNAPSHOT_PREFIX}{ts:.6f}"
319
+ mem.add(key, json.dumps(snapshot.to_dict()), kind="metrics")
320
+ return snapshot
321
+
322
+
323
+ def load_snapshots(
324
+ project_root: Path,
325
+ *,
326
+ limit: int = 50,
327
+ ) -> list[QualitySnapshot]:
328
+ """Retrieve recent quality snapshots from WorkspaceMemory, newest first."""
329
+ mem = WorkspaceMemory(project_root)
330
+ entries = mem.search(_SNAPSHOT_PREFIX, limit=limit * 3)
331
+
332
+ snapshots: list[QualitySnapshot] = []
333
+ for entry in entries:
334
+ if not entry.key.startswith(_SNAPSHOT_PREFIX):
335
+ continue
336
+ try:
337
+ data = json.loads(entry.content)
338
+ snapshots.append(QualitySnapshot.from_dict(data))
339
+ except Exception:
340
+ logger.debug("Skipping corrupt snapshot: %s", entry.key)
341
+ continue
342
+
343
+ snapshots.sort(key=lambda s: s.timestamp, reverse=True)
344
+ return snapshots[:limit]
345
+
346
+
347
+ # ── Trend analysis ───────────────────────────────────────────────────
348
+
349
+ @dataclass
350
+ class TrendResult:
351
+ """Result of trend analysis over metric snapshots."""
352
+
353
+ metric_name: str
354
+ snapshot_count: int
355
+ oldest_value: float
356
+ newest_value: float
357
+ delta: float
358
+ slope: float # per-second rate
359
+ direction: str # "improving", "stable", "degrading"
360
+
361
+ def to_dict(self) -> dict[str, Any]:
362
+ """Serialise trend analysis to a plain dictionary."""
363
+ return {
364
+ "metric_name": self.metric_name,
365
+ "snapshot_count": self.snapshot_count,
366
+ "oldest_value": round(self.oldest_value, 2),
367
+ "newest_value": round(self.newest_value, 2),
368
+ "delta": round(self.delta, 2),
369
+ "slope": self.slope,
370
+ "direction": self.direction,
371
+ }
372
+
373
+
374
+ def _linear_slope(xs: list[float], ys: list[float]) -> float:
375
+ """Simple linear regression slope (least squares)."""
376
+ n = len(xs)
377
+ if n < 2:
378
+ return 0.0
379
+ mean_x = sum(xs) / n
380
+ mean_y = sum(ys) / n
381
+ num = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(xs, ys))
382
+ den = sum((xi - mean_x) ** 2 for xi in xs)
383
+ if den == 0:
384
+ return 0.0
385
+ return num / den
386
+
387
+
388
+ def compute_trend(
389
+ snapshots: list[QualitySnapshot],
390
+ metric: str = "maintainability_index",
391
+ *,
392
+ higher_is_better: bool = True,
393
+ threshold: float = 0.01,
394
+ ) -> TrendResult:
395
+ """Compute trend for a given metric over sorted (newest-first) snapshots.
396
+
397
+ Args:
398
+ snapshots: Newest-first list of snapshots.
399
+ metric: Attribute name on QualitySnapshot to track.
400
+ higher_is_better: If True, positive slope means improving.
401
+ threshold: Fractional change below which trend is "stable".
402
+ """
403
+ if not snapshots:
404
+ return TrendResult(
405
+ metric_name=metric,
406
+ snapshot_count=0,
407
+ oldest_value=0.0,
408
+ newest_value=0.0,
409
+ delta=0.0,
410
+ slope=0.0,
411
+ direction="stable",
412
+ )
413
+
414
+ # Snapshots are newest-first; reverse for chronological order
415
+ ordered = list(reversed(snapshots))
416
+ ts_list = [s.timestamp for s in ordered]
417
+ vals = [float(getattr(s, metric, 0)) for s in ordered]
418
+
419
+ slope = _linear_slope(ts_list, vals)
420
+
421
+ oldest = vals[0]
422
+ newest = vals[-1]
423
+ delta = newest - oldest
424
+
425
+ # Determine direction
426
+ if len(snapshots) < 2:
427
+ direction = "stable"
428
+ else:
429
+ frac = abs(delta) / max(abs(oldest), 1e-9)
430
+ if frac < threshold:
431
+ direction = "stable"
432
+ elif (delta > 0) == higher_is_better:
433
+ direction = "improving"
434
+ else:
435
+ direction = "degrading"
436
+
437
+ return TrendResult(
438
+ metric_name=metric,
439
+ snapshot_count=len(snapshots),
440
+ oldest_value=oldest,
441
+ newest_value=newest,
442
+ delta=delta,
443
+ slope=slope,
444
+ direction=direction,
445
+ )
446
+
447
+
448
+ # ── Quality policies & gates ─────────────────────────────────────────
449
+
450
+ @dataclass
451
+ class QualityPolicy:
452
+ """Configurable quality thresholds for gate enforcement."""
453
+
454
+ min_maintainability: float = 40.0
455
+ max_complexity: int = 25
456
+ max_issues: int = 20
457
+ max_dead_code: int = 15
458
+ max_duplicates: int = 10
459
+ require_safety_pass: bool = True
460
+
461
+ def to_dict(self) -> dict[str, Any]:
462
+ """Serialise the quality policy to a plain dictionary."""
463
+ return {
464
+ "min_maintainability": self.min_maintainability,
465
+ "max_complexity": self.max_complexity,
466
+ "max_issues": self.max_issues,
467
+ "max_dead_code": self.max_dead_code,
468
+ "max_duplicates": self.max_duplicates,
469
+ "require_safety_pass": self.require_safety_pass,
470
+ }
471
+
472
+ @staticmethod
473
+ def from_dict(data: dict[str, Any]) -> QualityPolicy:
474
+ """Construct a :class:`QualityPolicy` from a dictionary."""
475
+ return QualityPolicy(
476
+ min_maintainability=data.get("min_maintainability", 40.0),
477
+ max_complexity=data.get("max_complexity", 25),
478
+ max_issues=data.get("max_issues", 20),
479
+ max_dead_code=data.get("max_dead_code", 15),
480
+ max_duplicates=data.get("max_duplicates", 10),
481
+ require_safety_pass=data.get("require_safety_pass", True),
482
+ )
483
+
484
+
485
+ @dataclass
486
+ class GateViolation:
487
+ """A single quality gate violation."""
488
+
489
+ rule: str
490
+ message: str
491
+ actual: float | int
492
+ threshold: float | int
493
+
494
+ def to_dict(self) -> dict[str, Any]:
495
+ """Serialise the gate violation to a plain dictionary."""
496
+ return {
497
+ "rule": self.rule,
498
+ "message": self.message,
499
+ "actual": self.actual,
500
+ "threshold": self.threshold,
501
+ }
502
+
503
+
504
+ @dataclass
505
+ class GateResult:
506
+ """Result of quality gate enforcement."""
507
+
508
+ passed: bool
509
+ violations: list[GateViolation] = field(default_factory=list)
510
+ policy: QualityPolicy = field(default_factory=QualityPolicy)
511
+
512
+ def to_dict(self) -> dict[str, Any]:
513
+ """Serialise the gate result to a plain dictionary."""
514
+ return {
515
+ "passed": self.passed,
516
+ "violations": [v.to_dict() for v in self.violations],
517
+ "policy": self.policy.to_dict(),
518
+ }
519
+
520
+
521
+ def enforce_quality_gate(
522
+ project_metrics: ProjectMetrics,
523
+ quality_report: QualityReport,
524
+ policy: QualityPolicy | None = None,
525
+ ) -> GateResult:
526
+ """Evaluate quality metrics against policy thresholds.
527
+
528
+ Returns a GateResult indicating pass/fail with violation details.
529
+ """
530
+ pol = policy or QualityPolicy()
531
+ violations: list[GateViolation] = []
532
+
533
+ # Maintainability
534
+ if project_metrics.maintainability_index < pol.min_maintainability:
535
+ violations.append(GateViolation(
536
+ rule="min_maintainability",
537
+ message=f"Maintainability index {project_metrics.maintainability_index:.1f} < {pol.min_maintainability}",
538
+ actual=project_metrics.maintainability_index,
539
+ threshold=pol.min_maintainability,
540
+ ))
541
+
542
+ # Max complexity
543
+ if project_metrics.max_complexity > pol.max_complexity:
544
+ violations.append(GateViolation(
545
+ rule="max_complexity",
546
+ message=f"Max complexity {project_metrics.max_complexity} > {pol.max_complexity}",
547
+ actual=project_metrics.max_complexity,
548
+ threshold=pol.max_complexity,
549
+ ))
550
+
551
+ # Total issues
552
+ if quality_report.issue_count > pol.max_issues:
553
+ violations.append(GateViolation(
554
+ rule="max_issues",
555
+ message=f"Issue count {quality_report.issue_count} > {pol.max_issues}",
556
+ actual=quality_report.issue_count,
557
+ threshold=pol.max_issues,
558
+ ))
559
+
560
+ # Dead code
561
+ if len(quality_report.dead_code) > pol.max_dead_code:
562
+ violations.append(GateViolation(
563
+ rule="max_dead_code",
564
+ message=f"Dead code count {len(quality_report.dead_code)} > {pol.max_dead_code}",
565
+ actual=len(quality_report.dead_code),
566
+ threshold=pol.max_dead_code,
567
+ ))
568
+
569
+ # Duplicates
570
+ if len(quality_report.duplicates) > pol.max_duplicates:
571
+ violations.append(GateViolation(
572
+ rule="max_duplicates",
573
+ message=f"Duplicate count {len(quality_report.duplicates)} > {pol.max_duplicates}",
574
+ actual=len(quality_report.duplicates),
575
+ threshold=pol.max_duplicates,
576
+ ))
577
+
578
+ # Safety
579
+ if pol.require_safety_pass and quality_report.safety and not quality_report.safety.safe:
580
+ violations.append(GateViolation(
581
+ rule="require_safety_pass",
582
+ message=f"Safety check failed with {len(quality_report.safety.issues)} issue(s)",
583
+ actual=len(quality_report.safety.issues),
584
+ threshold=0,
585
+ ))
586
+
587
+ return GateResult(
588
+ passed=len(violations) == 0,
589
+ violations=violations,
590
+ policy=pol,
591
+ )