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,477 @@
|
|
|
1
|
+
"""Tools 4, 12, 16: review context, affected flows, detect changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..changes import analyze_changes, parse_diff_ranges, parse_git_diff_ranges # noqa: F401
|
|
10
|
+
from ..context_savings import attach_context_savings, estimate_file_tokens
|
|
11
|
+
from ..flows import get_affected_flows as _get_affected_flows
|
|
12
|
+
from ..graph import edge_to_dict, node_to_dict
|
|
13
|
+
from ..hints import generate_hints, get_session
|
|
14
|
+
from ..incremental import get_changed_files, get_staged_and_unstaged
|
|
15
|
+
from ._common import _get_store, _resolve_graph_file_paths
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Tool 4: get_review_context
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_review_context(
|
|
26
|
+
changed_files: list[str] | None = None,
|
|
27
|
+
max_depth: int = 2,
|
|
28
|
+
include_source: bool = True,
|
|
29
|
+
max_lines_per_file: int = 200,
|
|
30
|
+
repo_root: str | None = None,
|
|
31
|
+
base: str = "HEAD~1",
|
|
32
|
+
detail_level: str = "standard",
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Generate a focused review context from changed files.
|
|
35
|
+
|
|
36
|
+
Builds a token-optimized subgraph + source snippets for code review.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
changed_files: Files to review (auto-detected from git diff if omitted).
|
|
40
|
+
max_depth: Impact radius depth (default: 2).
|
|
41
|
+
include_source: Whether to include source code snippets (default: True).
|
|
42
|
+
max_lines_per_file: Max source lines per file in output (default: 200).
|
|
43
|
+
repo_root: Repository root path. Auto-detected if omitted.
|
|
44
|
+
base: Git ref for change detection (default: HEAD~1).
|
|
45
|
+
detail_level: Output detail level. "standard" returns full context;
|
|
46
|
+
"minimal" returns summary, risk level, changed/impacted file counts,
|
|
47
|
+
top 5 key entity names, test gap count, and next tool suggestions.
|
|
48
|
+
Default: "standard".
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Structured review context with subgraph, source snippets, and
|
|
52
|
+
review guidance.
|
|
53
|
+
"""
|
|
54
|
+
store, root = _get_store(repo_root)
|
|
55
|
+
try:
|
|
56
|
+
# Get impact radius first
|
|
57
|
+
if changed_files is None:
|
|
58
|
+
changed_files = get_changed_files(root, base)
|
|
59
|
+
if not changed_files:
|
|
60
|
+
changed_files = get_staged_and_unstaged(root)
|
|
61
|
+
|
|
62
|
+
if not changed_files:
|
|
63
|
+
return {
|
|
64
|
+
"status": "ok",
|
|
65
|
+
"summary": "No changes detected. Nothing to review.",
|
|
66
|
+
"context": {},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
graph_files = _resolve_graph_file_paths(store, root, changed_files)
|
|
70
|
+
original_tokens = estimate_file_tokens(root, changed_files)
|
|
71
|
+
impact = store.get_impact_radius(graph_files, max_depth=max_depth)
|
|
72
|
+
|
|
73
|
+
if detail_level == "minimal":
|
|
74
|
+
impacted_count = len(impact["impacted_nodes"])
|
|
75
|
+
if impacted_count > 20:
|
|
76
|
+
risk = "high"
|
|
77
|
+
elif impacted_count > 5:
|
|
78
|
+
risk = "medium"
|
|
79
|
+
else:
|
|
80
|
+
risk = "low"
|
|
81
|
+
|
|
82
|
+
key_entities = [
|
|
83
|
+
n.name for n in impact["changed_nodes"][:5]
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# Count test gaps among changed functions.
|
|
87
|
+
changed_funcs = [
|
|
88
|
+
n for n in impact["changed_nodes"]
|
|
89
|
+
if n.kind == "Function" and not n.is_test
|
|
90
|
+
]
|
|
91
|
+
test_edges = [
|
|
92
|
+
e for e in impact["edges"] if e.kind == "TESTED_BY"
|
|
93
|
+
]
|
|
94
|
+
tested_qualified = {e.source_qualified for e in test_edges}
|
|
95
|
+
test_gap_count = sum(
|
|
96
|
+
1 for f in changed_funcs
|
|
97
|
+
if f.qualified_name not in tested_qualified
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
summary_parts = [
|
|
101
|
+
f"Review context for {len(changed_files)} changed file(s):",
|
|
102
|
+
f" - Risk: {risk}",
|
|
103
|
+
f" - {len(impact['impacted_nodes'])} impacted nodes"
|
|
104
|
+
f" in {len(impact['impacted_files'])} files",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
result = {
|
|
108
|
+
"status": "ok",
|
|
109
|
+
"summary": "\n".join(summary_parts),
|
|
110
|
+
"risk": risk,
|
|
111
|
+
"changed_file_count": len(changed_files),
|
|
112
|
+
"impacted_file_count": len(impact["impacted_files"]),
|
|
113
|
+
"key_entities": key_entities,
|
|
114
|
+
"test_gaps": test_gap_count,
|
|
115
|
+
"next_tool_suggestions": [
|
|
116
|
+
"detect_changes",
|
|
117
|
+
"get_affected_flows",
|
|
118
|
+
"get_impact_radius",
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
attach_context_savings(result, original_tokens=original_tokens)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
# Build review context
|
|
125
|
+
context: dict[str, Any] = {
|
|
126
|
+
"changed_files": changed_files,
|
|
127
|
+
"impacted_files": impact["impacted_files"],
|
|
128
|
+
"graph": {
|
|
129
|
+
"changed_nodes": [
|
|
130
|
+
node_to_dict(n) for n in impact["changed_nodes"]
|
|
131
|
+
],
|
|
132
|
+
"impacted_nodes": [
|
|
133
|
+
node_to_dict(n) for n in impact["impacted_nodes"]
|
|
134
|
+
],
|
|
135
|
+
"edges": [edge_to_dict(e) for e in impact["edges"]],
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Add source snippets for changed files
|
|
140
|
+
if include_source:
|
|
141
|
+
snippets = {}
|
|
142
|
+
for rel_path in changed_files:
|
|
143
|
+
full_path = root / rel_path
|
|
144
|
+
if full_path.is_file():
|
|
145
|
+
try:
|
|
146
|
+
lines = full_path.read_text(
|
|
147
|
+
errors="replace"
|
|
148
|
+
).splitlines()
|
|
149
|
+
if len(lines) > max_lines_per_file:
|
|
150
|
+
# Include only the relevant functions/classes
|
|
151
|
+
relevant_lines = _extract_relevant_lines(
|
|
152
|
+
lines,
|
|
153
|
+
impact["changed_nodes"],
|
|
154
|
+
str(full_path),
|
|
155
|
+
)
|
|
156
|
+
snippets[rel_path] = relevant_lines
|
|
157
|
+
else:
|
|
158
|
+
snippets[rel_path] = "\n".join(
|
|
159
|
+
f"{i+1}: {line}"
|
|
160
|
+
for i, line in enumerate(lines)
|
|
161
|
+
)
|
|
162
|
+
except (OSError, UnicodeDecodeError):
|
|
163
|
+
snippets[rel_path] = "(could not read file)"
|
|
164
|
+
context["source_snippets"] = snippets
|
|
165
|
+
|
|
166
|
+
# Generate review guidance
|
|
167
|
+
guidance = _generate_review_guidance(impact, changed_files)
|
|
168
|
+
context["review_guidance"] = guidance
|
|
169
|
+
|
|
170
|
+
summary_parts = [
|
|
171
|
+
f"Review context for {len(changed_files)} changed file(s):",
|
|
172
|
+
f" - {len(impact['changed_nodes'])} directly changed nodes",
|
|
173
|
+
f" - {len(impact['impacted_nodes'])} impacted nodes"
|
|
174
|
+
f" in {len(impact['impacted_files'])} files",
|
|
175
|
+
"",
|
|
176
|
+
"Review guidance:",
|
|
177
|
+
guidance,
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
result = {
|
|
181
|
+
"status": "ok",
|
|
182
|
+
"summary": "\n".join(summary_parts),
|
|
183
|
+
"context": context,
|
|
184
|
+
}
|
|
185
|
+
attach_context_savings(result, original_tokens=original_tokens)
|
|
186
|
+
return result
|
|
187
|
+
finally:
|
|
188
|
+
store.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_relevant_lines(
|
|
192
|
+
lines: list[str], nodes: list, file_path: str
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Extract only the lines relevant to changed nodes."""
|
|
195
|
+
ranges = []
|
|
196
|
+
for n in nodes:
|
|
197
|
+
if n.file_path == file_path:
|
|
198
|
+
start = max(0, n.line_start - 3) # 2 lines context before
|
|
199
|
+
end = min(len(lines), n.line_end + 2) # 1 line context after
|
|
200
|
+
ranges.append((start, end))
|
|
201
|
+
|
|
202
|
+
if not ranges:
|
|
203
|
+
# Show first N lines as fallback
|
|
204
|
+
return "\n".join(
|
|
205
|
+
f"{i+1}: {line}" for i, line in enumerate(lines[:50])
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Merge overlapping ranges
|
|
209
|
+
ranges.sort()
|
|
210
|
+
merged = [ranges[0]]
|
|
211
|
+
for start, end in ranges[1:]:
|
|
212
|
+
if start <= merged[-1][1] + 1:
|
|
213
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
214
|
+
else:
|
|
215
|
+
merged.append((start, end))
|
|
216
|
+
|
|
217
|
+
parts: list[str] = []
|
|
218
|
+
for start, end in merged:
|
|
219
|
+
if parts:
|
|
220
|
+
parts.append("...")
|
|
221
|
+
for i in range(start, end):
|
|
222
|
+
parts.append(f"{i+1}: {lines[i]}")
|
|
223
|
+
|
|
224
|
+
return "\n".join(parts)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _generate_review_guidance(
|
|
228
|
+
impact: dict, changed_files: list[str]
|
|
229
|
+
) -> str:
|
|
230
|
+
"""Generate review guidance based on the impact analysis."""
|
|
231
|
+
guidance_parts = []
|
|
232
|
+
|
|
233
|
+
# Check for test coverage
|
|
234
|
+
changed_funcs = [
|
|
235
|
+
n for n in impact["changed_nodes"] if n.kind == "Function"
|
|
236
|
+
]
|
|
237
|
+
test_edges = [e for e in impact["edges"] if e.kind == "TESTED_BY"]
|
|
238
|
+
tested_funcs = {e.source_qualified for e in test_edges}
|
|
239
|
+
|
|
240
|
+
untested = [
|
|
241
|
+
f for f in changed_funcs
|
|
242
|
+
if f.qualified_name not in tested_funcs and not f.is_test
|
|
243
|
+
]
|
|
244
|
+
if untested:
|
|
245
|
+
guidance_parts.append(
|
|
246
|
+
f"- {len(untested)} changed function(s) lack test coverage: "
|
|
247
|
+
+ ", ".join(n.name for n in untested[:5])
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Check for wide blast radius
|
|
251
|
+
if len(impact["impacted_nodes"]) > 20:
|
|
252
|
+
guidance_parts.append(
|
|
253
|
+
f"- Wide blast radius: {len(impact['impacted_nodes'])} "
|
|
254
|
+
"nodes impacted. "
|
|
255
|
+
"Review callers and dependents carefully."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Check for inheritance changes
|
|
259
|
+
inheritance_edges = [
|
|
260
|
+
e for e in impact["edges"]
|
|
261
|
+
if e.kind in ("INHERITS", "IMPLEMENTS")
|
|
262
|
+
]
|
|
263
|
+
if inheritance_edges:
|
|
264
|
+
guidance_parts.append(
|
|
265
|
+
f"- {len(inheritance_edges)} inheritance/implementation "
|
|
266
|
+
"relationship(s) affected. "
|
|
267
|
+
"Check for Liskov substitution violations."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Check for cross-file impact
|
|
271
|
+
impacted_file_count = len(impact["impacted_files"])
|
|
272
|
+
if impacted_file_count > 3:
|
|
273
|
+
guidance_parts.append(
|
|
274
|
+
f"- Changes impact {impacted_file_count} other files."
|
|
275
|
+
" Consider splitting into smaller PRs."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if not guidance_parts:
|
|
279
|
+
guidance_parts.append(
|
|
280
|
+
"- Changes appear well-contained with minimal blast radius."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return "\n".join(guidance_parts)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Tool 12: get_affected_flows [REVIEW]
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_affected_flows_func(
|
|
292
|
+
changed_files: list[str] | None = None,
|
|
293
|
+
base: str = "HEAD~1",
|
|
294
|
+
repo_root: str | None = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""Find execution flows affected by changed files.
|
|
297
|
+
|
|
298
|
+
[REVIEW] Identifies which execution flows pass through nodes in the
|
|
299
|
+
changed files. Useful during code review to understand which user-facing
|
|
300
|
+
or critical paths are affected by a change.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
changed_files: List of changed file paths (relative to repo root).
|
|
304
|
+
Auto-detected from git diff if omitted.
|
|
305
|
+
base: Git ref for auto-detecting changes (default: HEAD~1).
|
|
306
|
+
repo_root: Repository root path. Auto-detected if omitted.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Affected flows sorted by criticality, with step details.
|
|
310
|
+
"""
|
|
311
|
+
store, root = _get_store(repo_root)
|
|
312
|
+
try:
|
|
313
|
+
if changed_files is None:
|
|
314
|
+
changed_files = get_changed_files(root, base)
|
|
315
|
+
if not changed_files:
|
|
316
|
+
changed_files = get_staged_and_unstaged(root)
|
|
317
|
+
|
|
318
|
+
if not changed_files:
|
|
319
|
+
return {
|
|
320
|
+
"status": "ok",
|
|
321
|
+
"summary": "No changed files detected.",
|
|
322
|
+
"affected_flows": [],
|
|
323
|
+
"total": 0,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Convert to absolute paths for graph lookup
|
|
327
|
+
abs_files = [str(root / f) for f in changed_files]
|
|
328
|
+
result = _get_affected_flows(store, abs_files)
|
|
329
|
+
|
|
330
|
+
total = result["total"]
|
|
331
|
+
out = {
|
|
332
|
+
"status": "ok",
|
|
333
|
+
"summary": (
|
|
334
|
+
f"{total} flow(s) affected by changes "
|
|
335
|
+
f"in {len(changed_files)} file(s)"
|
|
336
|
+
),
|
|
337
|
+
"changed_files": changed_files,
|
|
338
|
+
"affected_flows": result["affected_flows"],
|
|
339
|
+
"total": total,
|
|
340
|
+
}
|
|
341
|
+
out["_hints"] = generate_hints(
|
|
342
|
+
"get_affected_flows", out, get_session()
|
|
343
|
+
)
|
|
344
|
+
return out
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
return {"status": "error", "error": str(exc)}
|
|
347
|
+
finally:
|
|
348
|
+
store.close()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Tool 16: detect_changes [REVIEW]
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def detect_changes_func(
|
|
357
|
+
base: str = "HEAD~1",
|
|
358
|
+
changed_files: list[str] | None = None,
|
|
359
|
+
include_source: bool = False,
|
|
360
|
+
max_depth: int = 2,
|
|
361
|
+
repo_root: str | None = None,
|
|
362
|
+
detail_level: str = "standard",
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
"""Detect changes and produce risk-scored review guidance.
|
|
365
|
+
|
|
366
|
+
[REVIEW] Primary tool for code review. Maps git diffs to affected
|
|
367
|
+
functions, flows, communities, and test coverage gaps. Returns
|
|
368
|
+
priority-ordered review guidance with risk scores.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
base: Git ref to diff against (default: HEAD~1).
|
|
372
|
+
changed_files: Explicit list of changed file paths (relative to repo
|
|
373
|
+
root). Auto-detected from git diff if omitted.
|
|
374
|
+
include_source: If True, include source code snippets for changed
|
|
375
|
+
functions. Default: False.
|
|
376
|
+
max_depth: Impact radius depth for BFS traversal. Default: 2.
|
|
377
|
+
repo_root: Repository root path. Auto-detected if omitted.
|
|
378
|
+
detail_level: Output detail level. "standard" returns full analysis;
|
|
379
|
+
"minimal" returns only summary, risk_score, changed_file_count,
|
|
380
|
+
test_gap_count, and top 3 review priorities (text only).
|
|
381
|
+
Default: "standard".
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Risk-scored analysis with changed functions, affected flows,
|
|
385
|
+
test gaps, and review priorities.
|
|
386
|
+
"""
|
|
387
|
+
store, root = _get_store(repo_root)
|
|
388
|
+
try:
|
|
389
|
+
# Detect changed files if not provided.
|
|
390
|
+
if changed_files is None:
|
|
391
|
+
changed_files = get_changed_files(root, base)
|
|
392
|
+
if not changed_files:
|
|
393
|
+
changed_files = get_staged_and_unstaged(root)
|
|
394
|
+
|
|
395
|
+
if not changed_files:
|
|
396
|
+
return {
|
|
397
|
+
"status": "ok",
|
|
398
|
+
"summary": "No changed files detected.",
|
|
399
|
+
"risk_score": 0.0,
|
|
400
|
+
"changed_functions": [],
|
|
401
|
+
"affected_flows": [],
|
|
402
|
+
"test_gaps": [],
|
|
403
|
+
"review_priorities": [],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
original_tokens = estimate_file_tokens(root, changed_files)
|
|
407
|
+
|
|
408
|
+
# Convert to absolute paths for graph lookup.
|
|
409
|
+
abs_files = [str(root / f) for f in changed_files]
|
|
410
|
+
|
|
411
|
+
# Parse diff ranges for line-level mapping.
|
|
412
|
+
diff_ranges = parse_diff_ranges(str(root), base)
|
|
413
|
+
# Remap to absolute paths so they match graph file_paths.
|
|
414
|
+
abs_ranges: dict[str, list[tuple[int, int]]] = {}
|
|
415
|
+
for rel_path, ranges in diff_ranges.items():
|
|
416
|
+
abs_path = str(root / rel_path)
|
|
417
|
+
abs_ranges[abs_path] = ranges
|
|
418
|
+
|
|
419
|
+
analysis = analyze_changes(
|
|
420
|
+
store,
|
|
421
|
+
changed_files=abs_files,
|
|
422
|
+
changed_ranges=abs_ranges if abs_ranges else None,
|
|
423
|
+
repo_root=str(root),
|
|
424
|
+
base=base,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Optionally include source snippets for changed functions.
|
|
428
|
+
if include_source:
|
|
429
|
+
for func in analysis.get("changed_functions", []):
|
|
430
|
+
fp = func.get("file_path")
|
|
431
|
+
ls = func.get("line_start")
|
|
432
|
+
le = func.get("line_end")
|
|
433
|
+
if fp and ls and le:
|
|
434
|
+
file_path = Path(fp)
|
|
435
|
+
if file_path.is_file():
|
|
436
|
+
try:
|
|
437
|
+
lines = file_path.read_text(
|
|
438
|
+
errors="replace"
|
|
439
|
+
).splitlines()
|
|
440
|
+
start = max(0, ls - 1)
|
|
441
|
+
end = min(len(lines), le)
|
|
442
|
+
func["source"] = "\n".join(
|
|
443
|
+
f"{i + 1}: {lines[i]}"
|
|
444
|
+
for i in range(start, end)
|
|
445
|
+
)
|
|
446
|
+
except (OSError, UnicodeDecodeError):
|
|
447
|
+
func["source"] = "(could not read file)"
|
|
448
|
+
|
|
449
|
+
if detail_level == "minimal":
|
|
450
|
+
priorities = analysis.get("review_priorities", [])
|
|
451
|
+
top_priorities = [
|
|
452
|
+
p.get("name", p.get("qualified_name", ""))
|
|
453
|
+
for p in priorities[:3]
|
|
454
|
+
]
|
|
455
|
+
result: dict[str, Any] = {
|
|
456
|
+
"status": "ok",
|
|
457
|
+
"summary": analysis.get("summary", ""),
|
|
458
|
+
"risk_score": analysis.get("risk_score", 0.0),
|
|
459
|
+
"changed_file_count": len(changed_files),
|
|
460
|
+
"test_gap_count": len(analysis.get("test_gaps", [])),
|
|
461
|
+
"review_priorities": top_priorities,
|
|
462
|
+
}
|
|
463
|
+
else:
|
|
464
|
+
result = {
|
|
465
|
+
"status": "ok",
|
|
466
|
+
"changed_files": changed_files,
|
|
467
|
+
**analysis,
|
|
468
|
+
}
|
|
469
|
+
result["_hints"] = generate_hints(
|
|
470
|
+
"detect_changes", result, get_session()
|
|
471
|
+
)
|
|
472
|
+
attach_context_savings(result, original_tokens=original_tokens)
|
|
473
|
+
return result
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
return {"status": "error", "error": str(exc)}
|
|
476
|
+
finally:
|
|
477
|
+
store.close()
|