code-review-graph-ultra 2.3.7__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 (85) hide show
  1. code_review_graph/__init__.py +20 -0
  2. code_review_graph/__main__.py +4 -0
  3. code_review_graph/analysis.py +410 -0
  4. code_review_graph/changes.py +530 -0
  5. code_review_graph/cli.py +2117 -0
  6. code_review_graph/communities.py +1088 -0
  7. code_review_graph/config_keys.py +33 -0
  8. code_review_graph/constants.py +73 -0
  9. code_review_graph/context_savings.py +317 -0
  10. code_review_graph/custom_languages.py +322 -0
  11. code_review_graph/daemon.py +1070 -0
  12. code_review_graph/daemon_cli.py +329 -0
  13. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +83 -0
  14. code_review_graph/embeddings.py +1140 -0
  15. code_review_graph/enrich.py +306 -0
  16. code_review_graph/eval/__init__.py +33 -0
  17. code_review_graph/eval/benchmarks/__init__.py +1 -0
  18. code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
  19. code_review_graph/eval/benchmarks/build_performance.py +60 -0
  20. code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
  21. code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
  22. code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
  23. code_review_graph/eval/benchmarks/search_quality.py +59 -0
  24. code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
  25. code_review_graph/eval/configs/code-review-graph.yaml +50 -0
  26. code_review_graph/eval/configs/express.yaml +45 -0
  27. code_review_graph/eval/configs/fastapi.yaml +48 -0
  28. code_review_graph/eval/configs/flask.yaml +50 -0
  29. code_review_graph/eval/configs/gin.yaml +51 -0
  30. code_review_graph/eval/configs/httpx.yaml +48 -0
  31. code_review_graph/eval/reporter.py +301 -0
  32. code_review_graph/eval/runner.py +225 -0
  33. code_review_graph/eval/scorer.py +85 -0
  34. code_review_graph/eval/token_benchmark.py +182 -0
  35. code_review_graph/event_resolver.py +122 -0
  36. code_review_graph/exports.py +447 -0
  37. code_review_graph/flows.py +714 -0
  38. code_review_graph/graph.py +1633 -0
  39. code_review_graph/graph_diff.py +122 -0
  40. code_review_graph/hcl_resolver.py +110 -0
  41. code_review_graph/hints.py +384 -0
  42. code_review_graph/incremental.py +1325 -0
  43. code_review_graph/jedi_resolver.py +303 -0
  44. code_review_graph/main.py +1288 -0
  45. code_review_graph/memory.py +142 -0
  46. code_review_graph/migrations.py +284 -0
  47. code_review_graph/parser.py +14182 -0
  48. code_review_graph/postprocessing.py +200 -0
  49. code_review_graph/prompts.py +159 -0
  50. code_review_graph/refactor.py +877 -0
  51. code_review_graph/registry.py +319 -0
  52. code_review_graph/rescript_resolver.py +206 -0
  53. code_review_graph/search.py +466 -0
  54. code_review_graph/skills.py +1873 -0
  55. code_review_graph/spring_resolver.py +200 -0
  56. code_review_graph/team_automation.py +387 -0
  57. code_review_graph/team_capture.py +690 -0
  58. code_review_graph/team_protocol.py +18 -0
  59. code_review_graph/team_server.py +289 -0
  60. code_review_graph/team_store.py +1120 -0
  61. code_review_graph/team_sync.py +752 -0
  62. code_review_graph/temporal_resolver.py +199 -0
  63. code_review_graph/token_benchmark.py +125 -0
  64. code_review_graph/tools/__init__.py +187 -0
  65. code_review_graph/tools/_common.py +291 -0
  66. code_review_graph/tools/analysis_tools.py +184 -0
  67. code_review_graph/tools/build.py +672 -0
  68. code_review_graph/tools/community_tools.py +246 -0
  69. code_review_graph/tools/context.py +152 -0
  70. code_review_graph/tools/docs.py +275 -0
  71. code_review_graph/tools/flows_tools.py +176 -0
  72. code_review_graph/tools/query.py +905 -0
  73. code_review_graph/tools/refactor_tools.py +168 -0
  74. code_review_graph/tools/registry_tools.py +125 -0
  75. code_review_graph/tools/review.py +477 -0
  76. code_review_graph/tools/team_tools.py +356 -0
  77. code_review_graph/tsconfig_resolver.py +257 -0
  78. code_review_graph/uninstall.py +1314 -0
  79. code_review_graph/visualization.py +2234 -0
  80. code_review_graph/wiki.py +305 -0
  81. code_review_graph_ultra-2.3.7.dist-info/METADATA +768 -0
  82. code_review_graph_ultra-2.3.7.dist-info/RECORD +85 -0
  83. code_review_graph_ultra-2.3.7.dist-info/WHEEL +4 -0
  84. code_review_graph_ultra-2.3.7.dist-info/entry_points.txt +3 -0
  85. code_review_graph_ultra-2.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,530 @@
1
+ """Change impact analysis for code review.
2
+
3
+ Maps git/svn diffs to affected functions, flows, communities, and test coverage
4
+ gaps. Produces risk-scored, priority-ordered review guidance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
17
+ from .flows import get_affected_flows
18
+ from .graph import GraphNode, GraphStore, _sanitize_name, node_to_dict
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _GIT_TIMEOUT = int(os.environ.get("CRG_GIT_TIMEOUT", "30")) # seconds, configurable
23
+
24
+ _SAFE_GIT_REF = re.compile(r"^[A-Za-z0-9_.~^/@{}\-]+$")
25
+ _SAFE_SVN_REV = re.compile(r"^r?\d+(:r?\d+|:HEAD|:BASE|:COMMITTED)?$", re.IGNORECASE)
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 1. parse_git_diff_ranges / parse_svn_diff_ranges
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def parse_git_diff_ranges(
34
+ repo_root: str,
35
+ base: str = "HEAD~1",
36
+ ) -> dict[str, list[tuple[int, int]]]:
37
+ """Run ``git diff --unified=0`` and extract changed line ranges per file.
38
+
39
+ Args:
40
+ repo_root: Absolute path to the repository root.
41
+ base: Git ref to diff against (default: ``HEAD~1``).
42
+
43
+ Returns:
44
+ Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
45
+ Returns an empty dict on error.
46
+ """
47
+ if not _SAFE_GIT_REF.match(base):
48
+ logger.warning("Invalid git ref rejected: %s", base)
49
+ return {}
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "diff", "--unified=0", base, "--"],
53
+ capture_output=True,
54
+ stdin=subprocess.DEVNULL,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ cwd=repo_root,
59
+ timeout=_GIT_TIMEOUT,
60
+ )
61
+ if result.returncode != 0:
62
+ logger.warning("git diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
63
+ return {}
64
+ except (OSError, subprocess.SubprocessError) as exc:
65
+ logger.warning("git diff error: %s", exc)
66
+ return {}
67
+
68
+ return _parse_unified_diff(result.stdout)
69
+
70
+
71
+ def parse_svn_diff_ranges(
72
+ repo_root: str,
73
+ rev_range: str | None = None,
74
+ ) -> dict[str, list[tuple[int, int]]]:
75
+ """Run ``svn diff`` and extract changed line ranges per file.
76
+
77
+ Args:
78
+ repo_root: Absolute path to the SVN working copy root.
79
+ rev_range: Optional SVN revision range in ``rXXX:HEAD`` format.
80
+ When *None*, diffs the working copy against BASE (local changes).
81
+
82
+ Returns:
83
+ Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
84
+ Returns an empty dict on error.
85
+ """
86
+ cmd = ["svn", "diff", "--non-interactive"]
87
+ if rev_range:
88
+ if not _SAFE_SVN_REV.match(rev_range):
89
+ logger.warning("Invalid SVN revision range rejected: %s", rev_range)
90
+ return {}
91
+ cmd.extend(["-r", rev_range])
92
+ try:
93
+ result = subprocess.run(
94
+ cmd,
95
+ capture_output=True,
96
+ stdin=subprocess.DEVNULL,
97
+ text=True,
98
+ encoding="utf-8",
99
+ errors="replace",
100
+ cwd=repo_root,
101
+ timeout=_GIT_TIMEOUT,
102
+ )
103
+ if result.returncode != 0:
104
+ logger.warning("svn diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
105
+ return {}
106
+ except (OSError, subprocess.SubprocessError) as exc:
107
+ logger.warning("svn diff error: %s", exc)
108
+ return {}
109
+
110
+ return _parse_unified_diff(result.stdout)
111
+
112
+
113
+ def parse_diff_ranges(
114
+ repo_root: str,
115
+ base: str = "HEAD~1",
116
+ ) -> dict[str, list[tuple[int, int]]]:
117
+ """Auto-detect VCS and return changed line ranges per file.
118
+
119
+ Dispatches to :func:`parse_git_diff_ranges` for Git repositories and
120
+ :func:`parse_svn_diff_ranges` for SVN working copies.
121
+
122
+ Args:
123
+ repo_root: Absolute path to the repository/working-copy root.
124
+ base: For Git: the ref to diff against (default ``HEAD~1``).
125
+ For SVN: an optional revision range (e.g. ``"r100:HEAD"``);
126
+ when *base* is not a valid SVN revision, working-copy changes
127
+ (``svn diff``) are used instead.
128
+ """
129
+ root_path = Path(repo_root)
130
+ if (root_path / ".svn").exists():
131
+ rev_range = base if _SAFE_SVN_REV.match(base) else None
132
+ return parse_svn_diff_ranges(repo_root, rev_range)
133
+ return parse_git_diff_ranges(repo_root, base)
134
+
135
+
136
+ def _parse_unified_diff(diff_text: str) -> dict[str, list[tuple[int, int]]]:
137
+ """Parse unified diff output into file -> line-range mappings.
138
+
139
+ Handles the ``@@ -old,count +new,count @@`` hunk header format.
140
+ """
141
+ ranges: dict[str, list[tuple[int, int]]] = {}
142
+ current_file: str | None = None
143
+
144
+ # Match "+++ b/path/to/file"
145
+ file_pattern = re.compile(r"^\+\+\+ b/(.+)$")
146
+ # Match "@@ ... +start,count @@" or "@@ ... +start @@"
147
+ hunk_pattern = re.compile(r"^@@ .+? \+(\d+)(?:,(\d+))? @@")
148
+
149
+ for line in diff_text.splitlines():
150
+ file_match = file_pattern.match(line)
151
+ if file_match:
152
+ current_file = file_match.group(1)
153
+ continue
154
+
155
+ hunk_match = hunk_pattern.match(line)
156
+ if hunk_match and current_file is not None:
157
+ start = int(hunk_match.group(1))
158
+ count = int(hunk_match.group(2)) if hunk_match.group(2) else 1
159
+ if count == 0:
160
+ # Pure deletion hunk (no lines added); still note the position.
161
+ end = start
162
+ else:
163
+ end = start + count - 1
164
+ ranges.setdefault(current_file, []).append((start, end))
165
+
166
+ return ranges
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # 2. compute_file_churn
171
+ # ---------------------------------------------------------------------------
172
+
173
+ _CHURN_SATURATION = 10.0
174
+ _CHURN_WEIGHT = 0.15
175
+ _NUMSTAT_COUNT = re.compile(r"^(?:\d+|-)$")
176
+
177
+
178
+ def _parse_numstat(log_text: str) -> dict[str, int]:
179
+ """Parse NUL-terminated ``git log --numstat -z`` records.
180
+
181
+ NUL termination is required for correctness: Git's default line format
182
+ quotes unusual paths, while ``-z`` preserves tabs and newlines in file
183
+ names without making the graph-path lookup ambiguous.
184
+ """
185
+ counts: dict[str, int] = {}
186
+ for record in log_text.split("\0"):
187
+ if not record:
188
+ continue
189
+ fields = record.split("\t", 2)
190
+ if len(fields) != 3:
191
+ continue
192
+ added, deleted, path = fields
193
+ if (
194
+ not path
195
+ or _NUMSTAT_COUNT.fullmatch(added) is None
196
+ or _NUMSTAT_COUNT.fullmatch(deleted) is None
197
+ ):
198
+ continue
199
+ counts[path] = counts.get(path, 0) + 1
200
+ return counts
201
+
202
+
203
+ def compute_file_churn(
204
+ repo_root: str,
205
+ window_days: int | None = None,
206
+ ) -> dict[str, int]:
207
+ """Count commits touching each file over a trailing window.
208
+
209
+ Returns an empty mapping when the window is invalid or Git cannot be
210
+ queried. Renames are deliberately not followed: churn belongs to the path
211
+ that existed in each commit.
212
+ """
213
+ if window_days is None:
214
+ raw_window = os.environ.get("CRG_CHURN_WINDOW_DAYS", "90")
215
+ try:
216
+ window_days = int(raw_window)
217
+ except ValueError:
218
+ logger.warning(
219
+ "Invalid CRG_CHURN_WINDOW_DAYS value %r; churn disabled",
220
+ raw_window,
221
+ )
222
+ return {}
223
+ if window_days <= 0:
224
+ return {}
225
+
226
+ try:
227
+ result = subprocess.run(
228
+ [
229
+ "git",
230
+ "-c",
231
+ "core.quotepath=off",
232
+ "log",
233
+ f"--since={window_days}.days.ago",
234
+ "--numstat",
235
+ "--no-renames",
236
+ "--format=",
237
+ "-z",
238
+ "--",
239
+ ],
240
+ capture_output=True,
241
+ stdin=subprocess.DEVNULL,
242
+ text=True,
243
+ encoding="utf-8",
244
+ errors="replace",
245
+ cwd=repo_root,
246
+ timeout=_GIT_TIMEOUT,
247
+ )
248
+ if result.returncode != 0:
249
+ logger.warning(
250
+ "git log failed (rc=%d): %s",
251
+ result.returncode,
252
+ result.stderr[:200],
253
+ )
254
+ return {}
255
+ except (OSError, subprocess.SubprocessError) as exc:
256
+ logger.warning("git log error: %s", exc)
257
+ return {}
258
+
259
+ return _parse_numstat(result.stdout)
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # 3. map_changes_to_nodes
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def map_changes_to_nodes(
268
+ store: GraphStore,
269
+ changed_ranges: dict[str, list[tuple[int, int]]],
270
+ ) -> list[GraphNode]:
271
+ """Find graph nodes whose line ranges overlap the changed lines.
272
+
273
+ Args:
274
+ store: The graph store.
275
+ changed_ranges: Mapping of file paths to ``(start, end)`` tuples.
276
+
277
+ Returns:
278
+ Deduplicated list of overlapping graph nodes.
279
+ """
280
+ seen: set[str] = set()
281
+ result: list[GraphNode] = []
282
+
283
+ for file_path, ranges in changed_ranges.items():
284
+ # Try the path as-is, then also try all nodes to match relative paths.
285
+ nodes = store.get_nodes_by_file(file_path)
286
+ if not nodes:
287
+ # The graph may store absolute paths; try a suffix match.
288
+ matched_paths = store.get_files_matching(file_path)
289
+ for mp in matched_paths:
290
+ nodes.extend(store.get_nodes_by_file(mp))
291
+
292
+ for node in nodes:
293
+ if node.qualified_name in seen:
294
+ continue
295
+ if node.line_start is None or node.line_end is None:
296
+ continue
297
+ # Check overlap with any changed range.
298
+ for start, end in ranges:
299
+ if node.line_start <= end and node.line_end >= start:
300
+ result.append(node)
301
+ seen.add(node.qualified_name)
302
+ break
303
+
304
+ return result
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # 4. compute_risk_score
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ def compute_risk_score(
313
+ store: GraphStore,
314
+ node: GraphNode,
315
+ churn_counts: dict[str, int] | None = None,
316
+ ) -> float:
317
+ """Compute a risk score (0.0 - 1.0) for a single node.
318
+
319
+ Scoring factors:
320
+ - Flow participation: 0.05 per flow membership, capped at 0.25
321
+ - Community crossing: 0.05 per caller from a different community, capped at 0.15
322
+ - Test coverage: 0.30 (untested) scaling down to 0.05 (5+ TESTED_BY edges)
323
+ - Security sensitivity: 0.20 if name matches security keywords
324
+ - Caller count: callers / 20, capped at 0.10
325
+ - Change frequency (opt-in): commits touching the file / 10, capped
326
+ at 0.15
327
+ """
328
+ score = 0.0
329
+
330
+ # --- Flow participation (cap 0.25), weighted by criticality ---
331
+ flow_criticalities = store.get_flow_criticalities_for_node(node.id)
332
+ if flow_criticalities:
333
+ score += min(sum(flow_criticalities), 0.25)
334
+ else:
335
+ flow_count = store.count_flow_memberships(node.id)
336
+ score += min(flow_count * 0.05, 0.25)
337
+
338
+ # --- Community crossing (cap 0.15) ---
339
+ callers = store.get_edges_by_target(node.qualified_name)
340
+ caller_edges = [e for e in callers if e.kind == "CALLS"]
341
+
342
+ cross_community = 0
343
+ node_cid = store.get_node_community_id(node.id)
344
+
345
+ if node_cid is not None and caller_edges:
346
+ caller_qns = [edge.source_qualified for edge in caller_edges]
347
+ cid_map = store.get_community_ids_by_qualified_names(caller_qns)
348
+ for cid in cid_map.values():
349
+ if cid is not None and cid != node_cid:
350
+ cross_community += 1
351
+ score += min(cross_community * 0.05, 0.15)
352
+
353
+ # --- Test coverage (direct + transitive) ---
354
+ transitive_tests = store.get_transitive_tests(node.qualified_name)
355
+ test_count = len(transitive_tests)
356
+ score += 0.30 - (min(test_count / 5.0, 1.0) * 0.25)
357
+
358
+ # --- Security sensitivity ---
359
+ name_lower = node.name.lower()
360
+ qn_lower = node.qualified_name.lower()
361
+ if any(kw in name_lower or kw in qn_lower for kw in _SECURITY_KEYWORDS):
362
+ score += 0.20
363
+
364
+ # --- Caller count (cap 0.10) ---
365
+ caller_count = len(caller_edges)
366
+ score += min(caller_count / 20.0, 0.10)
367
+
368
+ # --- Change frequency (opt-in, cap 0.15) ---
369
+ if churn_counts and node.file_path:
370
+ commit_count = churn_counts.get(node.file_path, 0)
371
+ score += min(commit_count / _CHURN_SATURATION, 1.0) * _CHURN_WEIGHT
372
+
373
+ return round(min(max(score, 0.0), 1.0), 4)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # 5. analyze_changes
378
+ # ---------------------------------------------------------------------------
379
+
380
+
381
+ def analyze_changes(
382
+ store: GraphStore,
383
+ changed_files: list[str],
384
+ changed_ranges: dict[str, list[tuple[int, int]]] | None = None,
385
+ repo_root: str | None = None,
386
+ base: str = "HEAD~1",
387
+ include_churn: bool = False,
388
+ ) -> dict[str, Any]:
389
+ """Analyze changes and produce risk-scored review guidance.
390
+
391
+ Args:
392
+ store: The graph store.
393
+ changed_files: List of changed file paths.
394
+ changed_ranges: Optional pre-parsed diff ranges. If not provided and
395
+ ``repo_root`` is given, they are computed via the detected VCS
396
+ (Git or SVN).
397
+ repo_root: Repository root (for git/svn diff).
398
+ base: Git ref or SVN revision range to diff against.
399
+ include_churn: Add an opt-in change-frequency term to each node's
400
+ risk score. The trailing window defaults to 90 days and can be
401
+ configured with ``CRG_CHURN_WINDOW_DAYS``.
402
+
403
+ Returns:
404
+ Dict with ``summary``, ``risk_score``, ``changed_functions``,
405
+ ``affected_flows``, ``test_gaps``, and ``review_priorities``.
406
+ """
407
+ # Compute changed ranges if not provided.
408
+ if changed_ranges is None and repo_root is not None:
409
+ # Diff keys are forward-slash paths relative to the repo root, but
410
+ # the graph stores absolute native paths. Remap so lookups work on
411
+ # Windows, where the LIKE-suffix fallback cannot bridge
412
+ # "src/app.py" to "C:\repo\src\app.py" (#528). Keys that are
413
+ # already absolute pass through pathlib joining unchanged. The
414
+ # explicit changed_ranges path (MCP) is untouched — tools/review.py
415
+ # remaps before calling, and remapping twice would corrupt keys.
416
+ root_path = Path(repo_root)
417
+ changed_ranges = {
418
+ str(root_path / key): ranges
419
+ for key, ranges in parse_diff_ranges(repo_root, base).items()
420
+ }
421
+
422
+ # Map changes to nodes.
423
+ if changed_ranges:
424
+ changed_nodes = map_changes_to_nodes(store, changed_ranges)
425
+ else:
426
+ # Fallback: all nodes in changed files.
427
+ changed_nodes = []
428
+ for fp in changed_files:
429
+ changed_nodes.extend(store.get_nodes_by_file(fp))
430
+
431
+ # RTL declarations are stored as Function nodes for compatibility but
432
+ # are not callable/testable functions.
433
+ changed_funcs = [
434
+ n for n in changed_nodes
435
+ if n.kind in ("Function", "Test", "Class")
436
+ and not n.extra.get("verilog_kind")
437
+ ]
438
+
439
+ # Cap to prevent O(N*M) query explosion on large PRs.
440
+ _max_funcs = int(os.environ.get("CRG_MAX_CHANGED_FUNCS", "500"))
441
+ funcs_truncated = len(changed_funcs) > _max_funcs
442
+ if funcs_truncated:
443
+ changed_funcs = changed_funcs[:_max_funcs]
444
+
445
+ churn_counts: dict[str, int] | None = None
446
+ if include_churn and repo_root is not None:
447
+ churn_counts = {}
448
+ root_path = Path(repo_root)
449
+ for key, count in compute_file_churn(repo_root).items():
450
+ churn_counts[key] = count
451
+ churn_counts[str(root_path / key)] = count
452
+
453
+ # Compute per-node risk scores.
454
+ node_risks: list[dict[str, Any]] = []
455
+ for node in changed_funcs:
456
+ risk = compute_risk_score(store, node, churn_counts)
457
+ node_risks.append({
458
+ **node_to_dict(node),
459
+ "risk_score": risk,
460
+ })
461
+
462
+ # Overall risk score: max of individual risks, or 0.
463
+ overall_risk = max((nr["risk_score"] for nr in node_risks), default=0.0)
464
+
465
+ # Affected flows.
466
+ affected = get_affected_flows(store, changed_files)
467
+
468
+ # Detect test gaps: changed functions without TESTED_BY edges.
469
+ test_gaps: list[dict[str, Any]] = []
470
+ for node in changed_funcs:
471
+ if node.is_test:
472
+ continue
473
+ # TESTED_BY edges are stored as source=production, target=test by the
474
+ # parser, so a changed production function finds its tests by source.
475
+ # See: #515
476
+ tested = store.get_edges_by_source(node.qualified_name)
477
+ if not any(e.kind == "TESTED_BY" for e in tested):
478
+ test_gaps.append({
479
+ "name": _sanitize_name(node.name),
480
+ "qualified_name": _sanitize_name(node.qualified_name),
481
+ "file": node.file_path,
482
+ "line_start": node.line_start,
483
+ "line_end": node.line_end,
484
+ })
485
+
486
+ # Review priorities: top 10 by risk score.
487
+ review_priorities = sorted(node_risks, key=lambda x: x["risk_score"], reverse=True)[:10]
488
+
489
+ # Build summary.
490
+ summary_parts = [
491
+ f"Analyzed {len(changed_files)} changed file(s):",
492
+ f" - {len(changed_funcs)} changed function(s)/class(es)",
493
+ f" - {affected['total']} affected flow(s)",
494
+ f" - {len(test_gaps)} test gap(s)",
495
+ f" - Overall risk score: {overall_risk:.2f}",
496
+ ]
497
+ if test_gaps:
498
+ # Dedup by bare name in the human summary. The underlying test_gaps
499
+ # list keeps every entry (a downstream consumer needs precision via
500
+ # qualified_name), but a graph that ended up with the same function
501
+ # stored under two qualified_names (e.g. relative + absolute path
502
+ # variants) would otherwise print "X, X, Y, Y" — surfacing graph
503
+ # corruption as a UX bug. The root cause is path normalization;
504
+ # this is the defensive last line.
505
+ seen_names: set[str] = set()
506
+ gap_names: list[str] = []
507
+ for g in test_gaps:
508
+ n = g["name"]
509
+ if n in seen_names:
510
+ continue
511
+ seen_names.add(n)
512
+ gap_names.append(n)
513
+ if len(gap_names) >= 5:
514
+ break
515
+ summary_parts.append(f" - Untested: {', '.join(gap_names)}")
516
+ if funcs_truncated:
517
+ summary_parts.append(
518
+ f" - Warning: analysis capped at {_max_funcs} functions "
519
+ f"(set CRG_MAX_CHANGED_FUNCS to adjust)"
520
+ )
521
+
522
+ return {
523
+ "summary": "\n".join(summary_parts),
524
+ "risk_score": overall_risk,
525
+ "changed_functions": node_risks,
526
+ "affected_flows": affected["affected_flows"],
527
+ "test_gaps": test_gaps,
528
+ "review_priorities": review_priorities,
529
+ "functions_truncated": funcs_truncated,
530
+ }