code-review-graph-codeblackwell 2.3.6.post1__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.
- code_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,409 @@
|
|
|
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. map_changes_to_nodes
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def map_changes_to_nodes(
|
|
175
|
+
store: GraphStore,
|
|
176
|
+
changed_ranges: dict[str, list[tuple[int, int]]],
|
|
177
|
+
) -> list[GraphNode]:
|
|
178
|
+
"""Find graph nodes whose line ranges overlap the changed lines.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
store: The graph store.
|
|
182
|
+
changed_ranges: Mapping of file paths to ``(start, end)`` tuples.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Deduplicated list of overlapping graph nodes.
|
|
186
|
+
"""
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
result: list[GraphNode] = []
|
|
189
|
+
|
|
190
|
+
for file_path, ranges in changed_ranges.items():
|
|
191
|
+
# Try the path as-is, then also try all nodes to match relative paths.
|
|
192
|
+
nodes = store.get_nodes_by_file(file_path)
|
|
193
|
+
if not nodes:
|
|
194
|
+
# The graph may store absolute paths; try a suffix match.
|
|
195
|
+
matched_paths = store.get_files_matching(file_path)
|
|
196
|
+
for mp in matched_paths:
|
|
197
|
+
nodes.extend(store.get_nodes_by_file(mp))
|
|
198
|
+
|
|
199
|
+
for node in nodes:
|
|
200
|
+
if node.qualified_name in seen:
|
|
201
|
+
continue
|
|
202
|
+
if node.line_start is None or node.line_end is None:
|
|
203
|
+
continue
|
|
204
|
+
# Check overlap with any changed range.
|
|
205
|
+
for start, end in ranges:
|
|
206
|
+
if node.line_start <= end and node.line_end >= start:
|
|
207
|
+
result.append(node)
|
|
208
|
+
seen.add(node.qualified_name)
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# 3. compute_risk_score
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
|
|
220
|
+
"""Compute a risk score (0.0 - 1.0) for a single node.
|
|
221
|
+
|
|
222
|
+
Scoring factors:
|
|
223
|
+
- Flow participation: 0.05 per flow membership, capped at 0.25
|
|
224
|
+
- Community crossing: 0.05 per caller from a different community, capped at 0.15
|
|
225
|
+
- Test coverage: 0.30 (untested) scaling down to 0.05 (5+ TESTED_BY edges)
|
|
226
|
+
- Security sensitivity: 0.20 if name matches security keywords
|
|
227
|
+
- Caller count: callers / 20, capped at 0.10
|
|
228
|
+
"""
|
|
229
|
+
score = 0.0
|
|
230
|
+
|
|
231
|
+
# --- Flow participation (cap 0.25), weighted by criticality ---
|
|
232
|
+
flow_criticalities = store.get_flow_criticalities_for_node(node.id)
|
|
233
|
+
if flow_criticalities:
|
|
234
|
+
score += min(sum(flow_criticalities), 0.25)
|
|
235
|
+
else:
|
|
236
|
+
flow_count = store.count_flow_memberships(node.id)
|
|
237
|
+
score += min(flow_count * 0.05, 0.25)
|
|
238
|
+
|
|
239
|
+
# --- Community crossing (cap 0.15) ---
|
|
240
|
+
callers = store.get_edges_by_target(node.qualified_name)
|
|
241
|
+
caller_edges = [e for e in callers if e.kind == "CALLS"]
|
|
242
|
+
|
|
243
|
+
cross_community = 0
|
|
244
|
+
node_cid = store.get_node_community_id(node.id)
|
|
245
|
+
|
|
246
|
+
if node_cid is not None and caller_edges:
|
|
247
|
+
caller_qns = [edge.source_qualified for edge in caller_edges]
|
|
248
|
+
cid_map = store.get_community_ids_by_qualified_names(caller_qns)
|
|
249
|
+
for cid in cid_map.values():
|
|
250
|
+
if cid is not None and cid != node_cid:
|
|
251
|
+
cross_community += 1
|
|
252
|
+
score += min(cross_community * 0.05, 0.15)
|
|
253
|
+
|
|
254
|
+
# --- Test coverage (direct + transitive) ---
|
|
255
|
+
transitive_tests = store.get_transitive_tests(node.qualified_name)
|
|
256
|
+
test_count = len(transitive_tests)
|
|
257
|
+
score += 0.30 - (min(test_count / 5.0, 1.0) * 0.25)
|
|
258
|
+
|
|
259
|
+
# --- Security sensitivity ---
|
|
260
|
+
name_lower = node.name.lower()
|
|
261
|
+
qn_lower = node.qualified_name.lower()
|
|
262
|
+
if any(kw in name_lower or kw in qn_lower for kw in _SECURITY_KEYWORDS):
|
|
263
|
+
score += 0.20
|
|
264
|
+
|
|
265
|
+
# --- Caller count (cap 0.10) ---
|
|
266
|
+
caller_count = len(caller_edges)
|
|
267
|
+
score += min(caller_count / 20.0, 0.10)
|
|
268
|
+
|
|
269
|
+
return round(min(max(score, 0.0), 1.0), 4)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# 4. analyze_changes
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def analyze_changes(
|
|
278
|
+
store: GraphStore,
|
|
279
|
+
changed_files: list[str],
|
|
280
|
+
changed_ranges: dict[str, list[tuple[int, int]]] | None = None,
|
|
281
|
+
repo_root: str | None = None,
|
|
282
|
+
base: str = "HEAD~1",
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
"""Analyze changes and produce risk-scored review guidance.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
store: The graph store.
|
|
288
|
+
changed_files: List of changed file paths.
|
|
289
|
+
changed_ranges: Optional pre-parsed diff ranges. If not provided and
|
|
290
|
+
``repo_root`` is given, they are computed via the detected VCS
|
|
291
|
+
(Git or SVN).
|
|
292
|
+
repo_root: Repository root (for git/svn diff).
|
|
293
|
+
base: Git ref or SVN revision range to diff against.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Dict with ``summary``, ``risk_score``, ``changed_functions``,
|
|
297
|
+
``affected_flows``, ``test_gaps``, and ``review_priorities``.
|
|
298
|
+
"""
|
|
299
|
+
# Compute changed ranges if not provided.
|
|
300
|
+
if changed_ranges is None and repo_root is not None:
|
|
301
|
+
# Diff keys are forward-slash paths relative to the repo root, but
|
|
302
|
+
# the graph stores absolute native paths. Remap so lookups work on
|
|
303
|
+
# Windows, where the LIKE-suffix fallback cannot bridge
|
|
304
|
+
# "src/app.py" to "C:\repo\src\app.py" (#528). Keys that are
|
|
305
|
+
# already absolute pass through pathlib joining unchanged. The
|
|
306
|
+
# explicit changed_ranges path (MCP) is untouched — tools/review.py
|
|
307
|
+
# remaps before calling, and remapping twice would corrupt keys.
|
|
308
|
+
root_path = Path(repo_root)
|
|
309
|
+
changed_ranges = {
|
|
310
|
+
str(root_path / key): ranges
|
|
311
|
+
for key, ranges in parse_diff_ranges(repo_root, base).items()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Map changes to nodes.
|
|
315
|
+
if changed_ranges:
|
|
316
|
+
changed_nodes = map_changes_to_nodes(store, changed_ranges)
|
|
317
|
+
else:
|
|
318
|
+
# Fallback: all nodes in changed files.
|
|
319
|
+
changed_nodes = []
|
|
320
|
+
for fp in changed_files:
|
|
321
|
+
changed_nodes.extend(store.get_nodes_by_file(fp))
|
|
322
|
+
|
|
323
|
+
# Filter to functions/tests for risk scoring (skip File nodes).
|
|
324
|
+
changed_funcs = [
|
|
325
|
+
n for n in changed_nodes
|
|
326
|
+
if n.kind in ("Function", "Test", "Class")
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
# Cap to prevent O(N*M) query explosion on large PRs.
|
|
330
|
+
_max_funcs = int(os.environ.get("CRG_MAX_CHANGED_FUNCS", "500"))
|
|
331
|
+
funcs_truncated = len(changed_funcs) > _max_funcs
|
|
332
|
+
if funcs_truncated:
|
|
333
|
+
changed_funcs = changed_funcs[:_max_funcs]
|
|
334
|
+
|
|
335
|
+
# Compute per-node risk scores.
|
|
336
|
+
node_risks: list[dict[str, Any]] = []
|
|
337
|
+
for node in changed_funcs:
|
|
338
|
+
risk = compute_risk_score(store, node)
|
|
339
|
+
node_risks.append({
|
|
340
|
+
**node_to_dict(node),
|
|
341
|
+
"risk_score": risk,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
# Overall risk score: max of individual risks, or 0.
|
|
345
|
+
overall_risk = max((nr["risk_score"] for nr in node_risks), default=0.0)
|
|
346
|
+
|
|
347
|
+
# Affected flows.
|
|
348
|
+
affected = get_affected_flows(store, changed_files)
|
|
349
|
+
|
|
350
|
+
# Detect test gaps: changed functions without TESTED_BY edges.
|
|
351
|
+
test_gaps: list[dict[str, Any]] = []
|
|
352
|
+
for node in changed_funcs:
|
|
353
|
+
if node.is_test:
|
|
354
|
+
continue
|
|
355
|
+
tested = store.get_edges_by_target(node.qualified_name)
|
|
356
|
+
if not any(e.kind == "TESTED_BY" for e in tested):
|
|
357
|
+
test_gaps.append({
|
|
358
|
+
"name": _sanitize_name(node.name),
|
|
359
|
+
"qualified_name": _sanitize_name(node.qualified_name),
|
|
360
|
+
"file": node.file_path,
|
|
361
|
+
"line_start": node.line_start,
|
|
362
|
+
"line_end": node.line_end,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
# Review priorities: top 10 by risk score.
|
|
366
|
+
review_priorities = sorted(node_risks, key=lambda x: x["risk_score"], reverse=True)[:10]
|
|
367
|
+
|
|
368
|
+
# Build summary.
|
|
369
|
+
summary_parts = [
|
|
370
|
+
f"Analyzed {len(changed_files)} changed file(s):",
|
|
371
|
+
f" - {len(changed_funcs)} changed function(s)/class(es)",
|
|
372
|
+
f" - {affected['total']} affected flow(s)",
|
|
373
|
+
f" - {len(test_gaps)} test gap(s)",
|
|
374
|
+
f" - Overall risk score: {overall_risk:.2f}",
|
|
375
|
+
]
|
|
376
|
+
if test_gaps:
|
|
377
|
+
# Dedup by bare name in the human summary. The underlying test_gaps
|
|
378
|
+
# list keeps every entry (a downstream consumer needs precision via
|
|
379
|
+
# qualified_name), but a graph that ended up with the same function
|
|
380
|
+
# stored under two qualified_names (e.g. relative + absolute path
|
|
381
|
+
# variants) would otherwise print "X, X, Y, Y" — surfacing graph
|
|
382
|
+
# corruption as a UX bug. The root cause is path normalization;
|
|
383
|
+
# this is the defensive last line.
|
|
384
|
+
seen_names: set[str] = set()
|
|
385
|
+
gap_names: list[str] = []
|
|
386
|
+
for g in test_gaps:
|
|
387
|
+
n = g["name"]
|
|
388
|
+
if n in seen_names:
|
|
389
|
+
continue
|
|
390
|
+
seen_names.add(n)
|
|
391
|
+
gap_names.append(n)
|
|
392
|
+
if len(gap_names) >= 5:
|
|
393
|
+
break
|
|
394
|
+
summary_parts.append(f" - Untested: {', '.join(gap_names)}")
|
|
395
|
+
if funcs_truncated:
|
|
396
|
+
summary_parts.append(
|
|
397
|
+
f" - Warning: analysis capped at {_max_funcs} functions "
|
|
398
|
+
f"(set CRG_MAX_CHANGED_FUNCS to adjust)"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
"summary": "\n".join(summary_parts),
|
|
403
|
+
"risk_score": overall_risk,
|
|
404
|
+
"changed_functions": node_risks,
|
|
405
|
+
"affected_flows": affected["affected_flows"],
|
|
406
|
+
"test_gaps": test_gaps,
|
|
407
|
+
"review_priorities": review_priorities,
|
|
408
|
+
"functions_truncated": funcs_truncated,
|
|
409
|
+
}
|