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.
- codexa-0.4.0.dist-info/METADATA +650 -0
- codexa-0.4.0.dist-info/RECORD +189 -0
- codexa-0.4.0.dist-info/WHEEL +5 -0
- codexa-0.4.0.dist-info/entry_points.txt +2 -0
- codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
- codexa-0.4.0.dist-info/top_level.txt +1 -0
- semantic_code_intelligence/__init__.py +5 -0
- semantic_code_intelligence/analysis/__init__.py +21 -0
- semantic_code_intelligence/analysis/ai_features.py +351 -0
- semantic_code_intelligence/bridge/__init__.py +28 -0
- semantic_code_intelligence/bridge/context_provider.py +245 -0
- semantic_code_intelligence/bridge/protocol.py +167 -0
- semantic_code_intelligence/bridge/server.py +348 -0
- semantic_code_intelligence/bridge/vscode.py +271 -0
- semantic_code_intelligence/ci/__init__.py +13 -0
- semantic_code_intelligence/ci/hooks.py +98 -0
- semantic_code_intelligence/ci/hotspots.py +272 -0
- semantic_code_intelligence/ci/impact.py +246 -0
- semantic_code_intelligence/ci/metrics.py +591 -0
- semantic_code_intelligence/ci/pr.py +412 -0
- semantic_code_intelligence/ci/quality.py +557 -0
- semantic_code_intelligence/ci/templates.py +164 -0
- semantic_code_intelligence/ci/trace.py +224 -0
- semantic_code_intelligence/cli/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
- semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
- semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
- semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
- semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
- semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
- semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
- semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
- semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
- semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
- semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
- semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
- semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
- semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
- semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
- semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
- semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
- semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
- semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
- semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
- semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
- semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
- semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
- semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
- semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
- semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
- semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
- semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
- semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
- semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
- semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
- semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
- semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
- semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
- semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
- semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
- semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
- semantic_code_intelligence/cli/main.py +65 -0
- semantic_code_intelligence/cli/router.py +92 -0
- semantic_code_intelligence/config/__init__.py +0 -0
- semantic_code_intelligence/config/settings.py +260 -0
- semantic_code_intelligence/context/__init__.py +19 -0
- semantic_code_intelligence/context/engine.py +429 -0
- semantic_code_intelligence/context/memory.py +253 -0
- semantic_code_intelligence/daemon/__init__.py +1 -0
- semantic_code_intelligence/daemon/watcher.py +515 -0
- semantic_code_intelligence/docs/__init__.py +1080 -0
- semantic_code_intelligence/embeddings/__init__.py +0 -0
- semantic_code_intelligence/embeddings/enhanced.py +131 -0
- semantic_code_intelligence/embeddings/generator.py +149 -0
- semantic_code_intelligence/embeddings/model_registry.py +100 -0
- semantic_code_intelligence/evolution/__init__.py +1 -0
- semantic_code_intelligence/evolution/budget_guard.py +111 -0
- semantic_code_intelligence/evolution/commit_manager.py +88 -0
- semantic_code_intelligence/evolution/context_builder.py +131 -0
- semantic_code_intelligence/evolution/engine.py +249 -0
- semantic_code_intelligence/evolution/patch_generator.py +229 -0
- semantic_code_intelligence/evolution/task_selector.py +214 -0
- semantic_code_intelligence/evolution/test_runner.py +111 -0
- semantic_code_intelligence/indexing/__init__.py +0 -0
- semantic_code_intelligence/indexing/chunker.py +174 -0
- semantic_code_intelligence/indexing/parallel.py +86 -0
- semantic_code_intelligence/indexing/scanner.py +146 -0
- semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
- semantic_code_intelligence/llm/__init__.py +62 -0
- semantic_code_intelligence/llm/cache.py +219 -0
- semantic_code_intelligence/llm/cached_provider.py +145 -0
- semantic_code_intelligence/llm/conversation.py +190 -0
- semantic_code_intelligence/llm/cross_refactor.py +272 -0
- semantic_code_intelligence/llm/investigation.py +274 -0
- semantic_code_intelligence/llm/mock_provider.py +77 -0
- semantic_code_intelligence/llm/ollama_provider.py +122 -0
- semantic_code_intelligence/llm/openai_provider.py +100 -0
- semantic_code_intelligence/llm/provider.py +92 -0
- semantic_code_intelligence/llm/rate_limiter.py +164 -0
- semantic_code_intelligence/llm/reasoning.py +438 -0
- semantic_code_intelligence/llm/safety.py +110 -0
- semantic_code_intelligence/llm/streaming.py +251 -0
- semantic_code_intelligence/lsp/__init__.py +609 -0
- semantic_code_intelligence/mcp/__init__.py +393 -0
- semantic_code_intelligence/parsing/__init__.py +19 -0
- semantic_code_intelligence/parsing/parser.py +375 -0
- semantic_code_intelligence/plugins/__init__.py +255 -0
- semantic_code_intelligence/plugins/examples/__init__.py +1 -0
- semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
- semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
- semantic_code_intelligence/scalability/__init__.py +205 -0
- semantic_code_intelligence/search/__init__.py +0 -0
- semantic_code_intelligence/search/formatter.py +123 -0
- semantic_code_intelligence/search/grep.py +361 -0
- semantic_code_intelligence/search/hybrid_search.py +170 -0
- semantic_code_intelligence/search/keyword_search.py +311 -0
- semantic_code_intelligence/search/section_expander.py +103 -0
- semantic_code_intelligence/services/__init__.py +0 -0
- semantic_code_intelligence/services/indexing_service.py +630 -0
- semantic_code_intelligence/services/search_service.py +269 -0
- semantic_code_intelligence/storage/__init__.py +0 -0
- semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
- semantic_code_intelligence/storage/hash_store.py +66 -0
- semantic_code_intelligence/storage/index_manifest.py +85 -0
- semantic_code_intelligence/storage/index_stats.py +138 -0
- semantic_code_intelligence/storage/query_history.py +160 -0
- semantic_code_intelligence/storage/symbol_registry.py +209 -0
- semantic_code_intelligence/storage/vector_store.py +297 -0
- semantic_code_intelligence/tests/__init__.py +0 -0
- semantic_code_intelligence/tests/test_ai_features.py +351 -0
- semantic_code_intelligence/tests/test_chunker.py +119 -0
- semantic_code_intelligence/tests/test_cli.py +188 -0
- semantic_code_intelligence/tests/test_config.py +154 -0
- semantic_code_intelligence/tests/test_context.py +381 -0
- semantic_code_intelligence/tests/test_embeddings.py +73 -0
- semantic_code_intelligence/tests/test_endtoend.py +1142 -0
- semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
- semantic_code_intelligence/tests/test_hash_store.py +79 -0
- semantic_code_intelligence/tests/test_logging.py +55 -0
- semantic_code_intelligence/tests/test_new_cli.py +138 -0
- semantic_code_intelligence/tests/test_parser.py +495 -0
- semantic_code_intelligence/tests/test_phase10.py +355 -0
- semantic_code_intelligence/tests/test_phase11.py +593 -0
- semantic_code_intelligence/tests/test_phase12.py +375 -0
- semantic_code_intelligence/tests/test_phase13.py +663 -0
- semantic_code_intelligence/tests/test_phase14.py +568 -0
- semantic_code_intelligence/tests/test_phase15.py +814 -0
- semantic_code_intelligence/tests/test_phase16.py +792 -0
- semantic_code_intelligence/tests/test_phase17.py +815 -0
- semantic_code_intelligence/tests/test_phase18.py +934 -0
- semantic_code_intelligence/tests/test_phase19.py +986 -0
- semantic_code_intelligence/tests/test_phase20.py +2753 -0
- semantic_code_intelligence/tests/test_phase20b.py +2058 -0
- semantic_code_intelligence/tests/test_phase20c.py +962 -0
- semantic_code_intelligence/tests/test_phase21.py +428 -0
- semantic_code_intelligence/tests/test_phase22.py +799 -0
- semantic_code_intelligence/tests/test_phase23.py +783 -0
- semantic_code_intelligence/tests/test_phase24.py +715 -0
- semantic_code_intelligence/tests/test_phase25.py +496 -0
- semantic_code_intelligence/tests/test_phase26.py +251 -0
- semantic_code_intelligence/tests/test_phase27.py +531 -0
- semantic_code_intelligence/tests/test_phase8.py +592 -0
- semantic_code_intelligence/tests/test_phase9.py +643 -0
- semantic_code_intelligence/tests/test_plugins.py +293 -0
- semantic_code_intelligence/tests/test_priority_features.py +727 -0
- semantic_code_intelligence/tests/test_router.py +41 -0
- semantic_code_intelligence/tests/test_scalability.py +138 -0
- semantic_code_intelligence/tests/test_scanner.py +125 -0
- semantic_code_intelligence/tests/test_search.py +160 -0
- semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
- semantic_code_intelligence/tests/test_tools.py +182 -0
- semantic_code_intelligence/tests/test_vector_store.py +151 -0
- semantic_code_intelligence/tests/test_watcher.py +211 -0
- semantic_code_intelligence/tools/__init__.py +442 -0
- semantic_code_intelligence/tools/executor.py +232 -0
- semantic_code_intelligence/tools/protocol.py +200 -0
- semantic_code_intelligence/tui/__init__.py +454 -0
- semantic_code_intelligence/utils/__init__.py +0 -0
- semantic_code_intelligence/utils/logging.py +112 -0
- semantic_code_intelligence/version.py +3 -0
- semantic_code_intelligence/web/__init__.py +11 -0
- semantic_code_intelligence/web/api.py +289 -0
- semantic_code_intelligence/web/server.py +397 -0
- semantic_code_intelligence/web/ui.py +659 -0
- semantic_code_intelligence/web/visualize.py +226 -0
- 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
|
+
)
|