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,412 @@
|
|
|
1
|
+
"""Pull Request intelligence — change summary, impact analysis, risk scoring.
|
|
2
|
+
|
|
3
|
+
All functions take plain data (file lists, symbol dicts) and produce
|
|
4
|
+
structured dict results suitable for JSON serialization or Rich rendering.
|
|
5
|
+
No git binary is invoked; the caller supplies file lists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from semantic_code_intelligence.parsing.parser import Symbol, parse_file, detect_language
|
|
16
|
+
from semantic_code_intelligence.context.engine import CallGraph, ContextBuilder, DependencyMap
|
|
17
|
+
from semantic_code_intelligence.llm.safety import SafetyReport, SafetyValidator
|
|
18
|
+
from semantic_code_intelligence.utils.logging import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger("ci.pr")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Change summary ───────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileChange:
|
|
27
|
+
"""Metadata for a single changed file."""
|
|
28
|
+
|
|
29
|
+
path: str
|
|
30
|
+
language: str | None = None
|
|
31
|
+
symbols_added: list[str] = field(default_factory=list)
|
|
32
|
+
symbols_removed: list[str] = field(default_factory=list)
|
|
33
|
+
symbols_modified: list[str] = field(default_factory=list)
|
|
34
|
+
import_changes: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"path": self.path,
|
|
39
|
+
"language": self.language,
|
|
40
|
+
"symbols_added": self.symbols_added,
|
|
41
|
+
"symbols_removed": self.symbols_removed,
|
|
42
|
+
"symbols_modified": self.symbols_modified,
|
|
43
|
+
"import_changes": self.import_changes,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChangeSummary:
|
|
49
|
+
"""Structured summary of a set of file changes."""
|
|
50
|
+
|
|
51
|
+
files_changed: int = 0
|
|
52
|
+
languages: list[str] = field(default_factory=list)
|
|
53
|
+
total_symbols_added: int = 0
|
|
54
|
+
total_symbols_removed: int = 0
|
|
55
|
+
total_symbols_modified: int = 0
|
|
56
|
+
file_details: list[FileChange] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"files_changed": self.files_changed,
|
|
61
|
+
"languages": self.languages,
|
|
62
|
+
"total_symbols_added": self.total_symbols_added,
|
|
63
|
+
"total_symbols_removed": self.total_symbols_removed,
|
|
64
|
+
"total_symbols_modified": self.total_symbols_modified,
|
|
65
|
+
"file_details": [f.to_dict() for f in self.file_details],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sym_set(symbols: list[Symbol], kind: str | None = None) -> dict[str, Symbol]:
|
|
70
|
+
"""Map name → Symbol, optionally filtering by kind."""
|
|
71
|
+
out: dict[str, Symbol] = {}
|
|
72
|
+
for s in symbols:
|
|
73
|
+
if kind and s.kind != kind:
|
|
74
|
+
continue
|
|
75
|
+
key = f"{s.name}:{s.start_line}" if s.kind != "import" else s.name
|
|
76
|
+
out[key] = s
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_change_summary(
|
|
81
|
+
changed_files: list[str],
|
|
82
|
+
*,
|
|
83
|
+
base_root: Path | None = None,
|
|
84
|
+
) -> ChangeSummary:
|
|
85
|
+
"""Build a structured change summary for a list of modified files.
|
|
86
|
+
|
|
87
|
+
For each file, parses current symbols. If *base_root* is supplied it
|
|
88
|
+
attempts to diff against the base version, but works fine without it
|
|
89
|
+
(reports all current symbols as "added").
|
|
90
|
+
"""
|
|
91
|
+
summary = ChangeSummary(files_changed=len(changed_files))
|
|
92
|
+
langs: set[str] = set()
|
|
93
|
+
|
|
94
|
+
for fpath in changed_files:
|
|
95
|
+
lang = detect_language(fpath)
|
|
96
|
+
if lang:
|
|
97
|
+
langs.add(lang)
|
|
98
|
+
|
|
99
|
+
fc = FileChange(path=fpath, language=lang)
|
|
100
|
+
|
|
101
|
+
if not lang:
|
|
102
|
+
summary.file_details.append(fc)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
current_syms = parse_file(fpath)
|
|
107
|
+
except Exception:
|
|
108
|
+
summary.file_details.append(fc)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Attempt base comparison
|
|
112
|
+
base_syms: list[Symbol] = []
|
|
113
|
+
if base_root:
|
|
114
|
+
base_file = base_root / fpath
|
|
115
|
+
if base_file.exists():
|
|
116
|
+
try:
|
|
117
|
+
base_syms = parse_file(str(base_file))
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
logger.debug("Could not parse base file %s: %s", base_file, exc)
|
|
120
|
+
|
|
121
|
+
cur_names = {s.name for s in current_syms if s.kind != "import"}
|
|
122
|
+
base_names = {s.name for s in base_syms if s.kind != "import"}
|
|
123
|
+
|
|
124
|
+
fc.symbols_added = sorted(cur_names - base_names)
|
|
125
|
+
fc.symbols_removed = sorted(base_names - cur_names)
|
|
126
|
+
|
|
127
|
+
# Detect "modified" — same name but different body
|
|
128
|
+
cur_by_name = {s.name: s for s in current_syms if s.kind != "import"}
|
|
129
|
+
base_by_name = {s.name: s for s in base_syms if s.kind != "import"}
|
|
130
|
+
for name in cur_names & base_names:
|
|
131
|
+
if cur_by_name[name].body != base_by_name.get(name, cur_by_name[name]).body:
|
|
132
|
+
fc.symbols_modified.append(name)
|
|
133
|
+
fc.symbols_modified.sort()
|
|
134
|
+
|
|
135
|
+
# Import diff
|
|
136
|
+
cur_imports = {s.name for s in current_syms if s.kind == "import"}
|
|
137
|
+
base_imports = {s.name for s in base_syms if s.kind == "import"}
|
|
138
|
+
added_imports = cur_imports - base_imports
|
|
139
|
+
removed_imports = base_imports - cur_imports
|
|
140
|
+
fc.import_changes = sorted(f"+{i}" for i in added_imports) + sorted(f"-{i}" for i in removed_imports)
|
|
141
|
+
|
|
142
|
+
summary.total_symbols_added += len(fc.symbols_added)
|
|
143
|
+
summary.total_symbols_removed += len(fc.symbols_removed)
|
|
144
|
+
summary.total_symbols_modified += len(fc.symbols_modified)
|
|
145
|
+
summary.file_details.append(fc)
|
|
146
|
+
|
|
147
|
+
summary.languages = sorted(langs)
|
|
148
|
+
return summary
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Semantic impact analysis ─────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class ImpactResult:
|
|
155
|
+
"""Impact analysis for a set of changed symbols."""
|
|
156
|
+
|
|
157
|
+
changed_symbols: list[str] = field(default_factory=list)
|
|
158
|
+
affected_files: list[str] = field(default_factory=list)
|
|
159
|
+
affected_symbols: list[str] = field(default_factory=list)
|
|
160
|
+
dependency_changes: list[str] = field(default_factory=list)
|
|
161
|
+
|
|
162
|
+
def to_dict(self) -> dict[str, Any]:
|
|
163
|
+
return {
|
|
164
|
+
"changed_symbols": self.changed_symbols,
|
|
165
|
+
"affected_files": self.affected_files,
|
|
166
|
+
"affected_symbols": self.affected_symbols,
|
|
167
|
+
"dependency_changes": self.dependency_changes,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def analyze_impact(
|
|
172
|
+
changed_files: list[str],
|
|
173
|
+
project_root: Path,
|
|
174
|
+
) -> ImpactResult:
|
|
175
|
+
"""Determine which symbols and files are affected by changes.
|
|
176
|
+
|
|
177
|
+
Indexes the project, builds a call graph, then traces callers of
|
|
178
|
+
any modified symbol to find the blast radius.
|
|
179
|
+
"""
|
|
180
|
+
builder = ContextBuilder()
|
|
181
|
+
dep_map = DependencyMap()
|
|
182
|
+
|
|
183
|
+
# Index changed files
|
|
184
|
+
changed_syms: set[str] = set()
|
|
185
|
+
for fpath in changed_files:
|
|
186
|
+
try:
|
|
187
|
+
syms = builder.index_file(fpath)
|
|
188
|
+
dep_map.add_file(fpath)
|
|
189
|
+
for s in syms:
|
|
190
|
+
if s.kind != "import":
|
|
191
|
+
changed_syms.add(s.name)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.debug("Could not index %s for impact: %s", fpath, exc)
|
|
194
|
+
|
|
195
|
+
# Build call graph from all indexed symbols
|
|
196
|
+
all_syms = builder.get_all_symbols()
|
|
197
|
+
cg = CallGraph()
|
|
198
|
+
cg.build(all_syms)
|
|
199
|
+
|
|
200
|
+
affected_syms: set[str] = set()
|
|
201
|
+
affected_files: set[str] = set()
|
|
202
|
+
|
|
203
|
+
for name in changed_syms:
|
|
204
|
+
for edge in cg.callers_of(name):
|
|
205
|
+
caller = edge.caller
|
|
206
|
+
if ":" in caller:
|
|
207
|
+
caller = caller.rsplit(":", 1)[-1]
|
|
208
|
+
affected_syms.add(caller)
|
|
209
|
+
affected_files.add(edge.file_path)
|
|
210
|
+
|
|
211
|
+
# Dependency-level impact: who imports these files?
|
|
212
|
+
dep_changes: list[str] = []
|
|
213
|
+
changed_set = {str(Path(f).resolve()) for f in changed_files}
|
|
214
|
+
for f in dep_map.get_all_files():
|
|
215
|
+
for dep in dep_map.get_dependencies(f):
|
|
216
|
+
if any(dep.import_text in str(cf) for cf in changed_set):
|
|
217
|
+
dep_changes.append(f"{f} imports {dep.import_text}")
|
|
218
|
+
|
|
219
|
+
return ImpactResult(
|
|
220
|
+
changed_symbols=sorted(changed_syms),
|
|
221
|
+
affected_files=sorted(affected_files),
|
|
222
|
+
affected_symbols=sorted(affected_syms),
|
|
223
|
+
dependency_changes=dep_changes,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Suggested reviewers ──────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
def suggest_reviewers(
|
|
230
|
+
changed_files: list[str],
|
|
231
|
+
*,
|
|
232
|
+
all_files: list[str] | None = None,
|
|
233
|
+
) -> list[dict[str, Any]]:
|
|
234
|
+
"""Suggest reviewers based on file domain expertise.
|
|
235
|
+
|
|
236
|
+
Returns a list of domain areas with associated file patterns so a team
|
|
237
|
+
can assign reviewers by area. This is a heuristic, not git-blame based.
|
|
238
|
+
"""
|
|
239
|
+
domains: dict[str, list[str]] = {}
|
|
240
|
+
|
|
241
|
+
for fpath in changed_files:
|
|
242
|
+
parts = Path(fpath).parts
|
|
243
|
+
# Use first two meaningful directories as domain
|
|
244
|
+
meaningful = [p for p in parts if not p.startswith(".") and p not in ("src", "lib")]
|
|
245
|
+
domain = "/".join(meaningful[:2]) if len(meaningful) >= 2 else (meaningful[0] if meaningful else "root")
|
|
246
|
+
domains.setdefault(domain, []).append(fpath)
|
|
247
|
+
|
|
248
|
+
return [
|
|
249
|
+
{"domain": domain, "files": files, "file_count": len(files)}
|
|
250
|
+
for domain, files in sorted(domains.items(), key=lambda x: -len(x[1]))
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── Risk severity scoring ────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
@dataclass
|
|
257
|
+
class RiskScore:
|
|
258
|
+
"""Aggregate risk assessment for a changeset."""
|
|
259
|
+
|
|
260
|
+
score: int # 0-100
|
|
261
|
+
level: str # "low", "medium", "high", "critical"
|
|
262
|
+
factors: list[str] = field(default_factory=list)
|
|
263
|
+
|
|
264
|
+
def to_dict(self) -> dict[str, Any]:
|
|
265
|
+
return {
|
|
266
|
+
"score": self.score,
|
|
267
|
+
"level": self.level,
|
|
268
|
+
"factors": self.factors,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _risk_level(score: int) -> str:
|
|
273
|
+
if score < 25:
|
|
274
|
+
return "low"
|
|
275
|
+
if score < 50:
|
|
276
|
+
return "medium"
|
|
277
|
+
if score < 75:
|
|
278
|
+
return "high"
|
|
279
|
+
return "critical"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def compute_risk(
|
|
283
|
+
change_summary: ChangeSummary,
|
|
284
|
+
*,
|
|
285
|
+
safety_report: SafetyReport | None = None,
|
|
286
|
+
impact: ImpactResult | None = None,
|
|
287
|
+
) -> RiskScore:
|
|
288
|
+
"""Compute a risk severity score (0-100) for a changeset.
|
|
289
|
+
|
|
290
|
+
Factors considered:
|
|
291
|
+
- Number of files changed
|
|
292
|
+
- Symbol additions/removals
|
|
293
|
+
- Safety issues
|
|
294
|
+
- Blast radius (affected symbols)
|
|
295
|
+
"""
|
|
296
|
+
score = 0
|
|
297
|
+
factors: list[str] = []
|
|
298
|
+
|
|
299
|
+
# File count factor
|
|
300
|
+
n_files = change_summary.files_changed
|
|
301
|
+
if n_files > 20:
|
|
302
|
+
score += 20
|
|
303
|
+
factors.append(f"Large changeset: {n_files} files")
|
|
304
|
+
elif n_files > 10:
|
|
305
|
+
score += 10
|
|
306
|
+
factors.append(f"Medium changeset: {n_files} files")
|
|
307
|
+
elif n_files > 0:
|
|
308
|
+
score += 5
|
|
309
|
+
|
|
310
|
+
# Symbol removals are riskier than additions
|
|
311
|
+
n_removed = change_summary.total_symbols_removed
|
|
312
|
+
if n_removed > 10:
|
|
313
|
+
score += 20
|
|
314
|
+
factors.append(f"{n_removed} symbols removed")
|
|
315
|
+
elif n_removed > 0:
|
|
316
|
+
score += 10
|
|
317
|
+
factors.append(f"{n_removed} symbols removed")
|
|
318
|
+
|
|
319
|
+
n_modified = change_summary.total_symbols_modified
|
|
320
|
+
if n_modified > 10:
|
|
321
|
+
score += 15
|
|
322
|
+
factors.append(f"{n_modified} symbols modified")
|
|
323
|
+
elif n_modified > 0:
|
|
324
|
+
score += 5
|
|
325
|
+
|
|
326
|
+
# Safety issues
|
|
327
|
+
if safety_report and not safety_report.safe:
|
|
328
|
+
n_issues = len(safety_report.issues)
|
|
329
|
+
score += min(30, n_issues * 10)
|
|
330
|
+
factors.append(f"{n_issues} safety issue(s)")
|
|
331
|
+
|
|
332
|
+
# Blast radius
|
|
333
|
+
if impact:
|
|
334
|
+
n_affected = len(impact.affected_symbols)
|
|
335
|
+
if n_affected > 20:
|
|
336
|
+
score += 15
|
|
337
|
+
factors.append(f"Wide blast radius: {n_affected} affected symbols")
|
|
338
|
+
elif n_affected > 5:
|
|
339
|
+
score += 10
|
|
340
|
+
factors.append(f"{n_affected} affected symbols")
|
|
341
|
+
|
|
342
|
+
score = min(100, score)
|
|
343
|
+
return RiskScore(score=score, level=_risk_level(score), factors=factors)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── Full PR report ───────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
@dataclass
|
|
349
|
+
class PRReport:
|
|
350
|
+
"""Complete PR intelligence report."""
|
|
351
|
+
|
|
352
|
+
change_summary: ChangeSummary
|
|
353
|
+
impact: ImpactResult | None = None
|
|
354
|
+
reviewers: list[dict[str, Any]] = field(default_factory=list)
|
|
355
|
+
risk: RiskScore | None = None
|
|
356
|
+
safety: SafetyReport | None = None
|
|
357
|
+
|
|
358
|
+
def to_dict(self) -> dict[str, Any]:
|
|
359
|
+
return {
|
|
360
|
+
"change_summary": self.change_summary.to_dict(),
|
|
361
|
+
"impact": self.impact.to_dict() if self.impact else None,
|
|
362
|
+
"reviewers": self.reviewers,
|
|
363
|
+
"risk": self.risk.to_dict() if self.risk else None,
|
|
364
|
+
"safety": self.safety.to_dict() if self.safety else None,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def generate_pr_report(
|
|
369
|
+
changed_files: list[str],
|
|
370
|
+
project_root: Path,
|
|
371
|
+
*,
|
|
372
|
+
run_impact: bool = True,
|
|
373
|
+
run_safety: bool = True,
|
|
374
|
+
) -> PRReport:
|
|
375
|
+
"""Generate a full PR intelligence report.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
changed_files: Paths of files in the changeset.
|
|
379
|
+
project_root: Repository root directory.
|
|
380
|
+
run_impact: Whether to run impact analysis (requires indexing).
|
|
381
|
+
run_safety: Whether to run safety validation.
|
|
382
|
+
"""
|
|
383
|
+
summary = build_change_summary(changed_files)
|
|
384
|
+
|
|
385
|
+
impact = None
|
|
386
|
+
if run_impact:
|
|
387
|
+
try:
|
|
388
|
+
impact = analyze_impact(changed_files, project_root)
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
logger.debug("Impact analysis skipped: %s", exc)
|
|
391
|
+
|
|
392
|
+
safety = None
|
|
393
|
+
if run_safety:
|
|
394
|
+
validator = SafetyValidator()
|
|
395
|
+
code = ""
|
|
396
|
+
for fpath in changed_files:
|
|
397
|
+
try:
|
|
398
|
+
code += Path(fpath).read_text(encoding="utf-8", errors="replace") + "\n"
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
logger.debug("Could not read %s for safety check: %s", fpath, exc)
|
|
401
|
+
safety = validator.validate(code)
|
|
402
|
+
|
|
403
|
+
reviewers = suggest_reviewers(changed_files)
|
|
404
|
+
risk = compute_risk(summary, safety_report=safety, impact=impact)
|
|
405
|
+
|
|
406
|
+
return PRReport(
|
|
407
|
+
change_summary=summary,
|
|
408
|
+
impact=impact,
|
|
409
|
+
reviewers=reviewers,
|
|
410
|
+
risk=risk,
|
|
411
|
+
safety=safety,
|
|
412
|
+
)
|