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,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
+ )