code-review-graph-codeblackwell 2.4.0__py3-none-any.whl → 3.0.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.
- code_review_graph/__init__.py +1 -1
- code_review_graph/changes.py +128 -7
- code_review_graph/cli.py +592 -61
- code_review_graph/communities.py +235 -21
- code_review_graph/config_keys.py +33 -0
- code_review_graph/constants.py +51 -1
- code_review_graph/css_resolver.py +155 -0
- code_review_graph/daemon.py +67 -6
- code_review_graph/daemon_cli.py +12 -3
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +2 -2
- code_review_graph/embeddings.py +157 -23
- code_review_graph/enrich.py +8 -5
- code_review_graph/eval/configs/fastapi.yaml +7 -7
- code_review_graph/eval/runner.py +16 -2
- code_review_graph/event_resolver.py +122 -0
- code_review_graph/exports.py +39 -1
- code_review_graph/flows.py +16 -0
- code_review_graph/graph.py +567 -243
- code_review_graph/hcl_resolver.py +110 -0
- code_review_graph/incremental.py +197 -309
- code_review_graph/main.py +183 -119
- code_review_graph/migrations.py +13 -0
- code_review_graph/parser.py +9580 -2760
- code_review_graph/postprocessing.py +72 -6
- code_review_graph/refactor.py +32 -7
- code_review_graph/search.py +19 -0
- code_review_graph/skills.py +289 -57
- code_review_graph/tools/__init__.py +2 -0
- code_review_graph/tools/_common.py +115 -0
- code_review_graph/tools/build.py +131 -0
- code_review_graph/tools/docs.py +3 -2
- code_review_graph/tools/query.py +289 -91
- code_review_graph/tools/review.py +0 -8
- code_review_graph/uninstall.py +1265 -0
- code_review_graph/visualization.py +76 -45
- {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/METADATA +58 -24
- {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/RECORD +40 -35
- {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/WHEEL +0 -0
- {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/entry_points.txt +0 -0
- {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/licenses/LICENSE +0 -0
code_review_graph/__init__.py
CHANGED
code_review_graph/changes.py
CHANGED
|
@@ -167,7 +167,100 @@ def _parse_unified_diff(diff_text: str) -> dict[str, list[tuple[int, int]]]:
|
|
|
167
167
|
|
|
168
168
|
|
|
169
169
|
# ---------------------------------------------------------------------------
|
|
170
|
-
# 2.
|
|
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
|
|
171
264
|
# ---------------------------------------------------------------------------
|
|
172
265
|
|
|
173
266
|
|
|
@@ -212,11 +305,15 @@ def map_changes_to_nodes(
|
|
|
212
305
|
|
|
213
306
|
|
|
214
307
|
# ---------------------------------------------------------------------------
|
|
215
|
-
#
|
|
308
|
+
# 4. compute_risk_score
|
|
216
309
|
# ---------------------------------------------------------------------------
|
|
217
310
|
|
|
218
311
|
|
|
219
|
-
def compute_risk_score(
|
|
312
|
+
def compute_risk_score(
|
|
313
|
+
store: GraphStore,
|
|
314
|
+
node: GraphNode,
|
|
315
|
+
churn_counts: dict[str, int] | None = None,
|
|
316
|
+
) -> float:
|
|
220
317
|
"""Compute a risk score (0.0 - 1.0) for a single node.
|
|
221
318
|
|
|
222
319
|
Scoring factors:
|
|
@@ -225,6 +322,8 @@ def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
|
|
|
225
322
|
- Test coverage: 0.30 (untested) scaling down to 0.05 (5+ TESTED_BY edges)
|
|
226
323
|
- Security sensitivity: 0.20 if name matches security keywords
|
|
227
324
|
- Caller count: callers / 20, capped at 0.10
|
|
325
|
+
- Change frequency (opt-in): commits touching the file / 10, capped
|
|
326
|
+
at 0.15
|
|
228
327
|
"""
|
|
229
328
|
score = 0.0
|
|
230
329
|
|
|
@@ -266,11 +365,16 @@ def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
|
|
|
266
365
|
caller_count = len(caller_edges)
|
|
267
366
|
score += min(caller_count / 20.0, 0.10)
|
|
268
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
|
+
|
|
269
373
|
return round(min(max(score, 0.0), 1.0), 4)
|
|
270
374
|
|
|
271
375
|
|
|
272
376
|
# ---------------------------------------------------------------------------
|
|
273
|
-
#
|
|
377
|
+
# 5. analyze_changes
|
|
274
378
|
# ---------------------------------------------------------------------------
|
|
275
379
|
|
|
276
380
|
|
|
@@ -280,6 +384,7 @@ def analyze_changes(
|
|
|
280
384
|
changed_ranges: dict[str, list[tuple[int, int]]] | None = None,
|
|
281
385
|
repo_root: str | None = None,
|
|
282
386
|
base: str = "HEAD~1",
|
|
387
|
+
include_churn: bool = False,
|
|
283
388
|
) -> dict[str, Any]:
|
|
284
389
|
"""Analyze changes and produce risk-scored review guidance.
|
|
285
390
|
|
|
@@ -291,6 +396,9 @@ def analyze_changes(
|
|
|
291
396
|
(Git or SVN).
|
|
292
397
|
repo_root: Repository root (for git/svn diff).
|
|
293
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``.
|
|
294
402
|
|
|
295
403
|
Returns:
|
|
296
404
|
Dict with ``summary``, ``risk_score``, ``changed_functions``,
|
|
@@ -320,10 +428,12 @@ def analyze_changes(
|
|
|
320
428
|
for fp in changed_files:
|
|
321
429
|
changed_nodes.extend(store.get_nodes_by_file(fp))
|
|
322
430
|
|
|
323
|
-
#
|
|
431
|
+
# RTL declarations are stored as Function nodes for compatibility but
|
|
432
|
+
# are not callable/testable functions.
|
|
324
433
|
changed_funcs = [
|
|
325
434
|
n for n in changed_nodes
|
|
326
435
|
if n.kind in ("Function", "Test", "Class")
|
|
436
|
+
and not n.extra.get("verilog_kind")
|
|
327
437
|
]
|
|
328
438
|
|
|
329
439
|
# Cap to prevent O(N*M) query explosion on large PRs.
|
|
@@ -332,10 +442,18 @@ def analyze_changes(
|
|
|
332
442
|
if funcs_truncated:
|
|
333
443
|
changed_funcs = changed_funcs[:_max_funcs]
|
|
334
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
|
+
|
|
335
453
|
# Compute per-node risk scores.
|
|
336
454
|
node_risks: list[dict[str, Any]] = []
|
|
337
455
|
for node in changed_funcs:
|
|
338
|
-
risk = compute_risk_score(store, node)
|
|
456
|
+
risk = compute_risk_score(store, node, churn_counts)
|
|
339
457
|
node_risks.append({
|
|
340
458
|
**node_to_dict(node),
|
|
341
459
|
"risk_score": risk,
|
|
@@ -352,7 +470,10 @@ def analyze_changes(
|
|
|
352
470
|
for node in changed_funcs:
|
|
353
471
|
if node.is_test:
|
|
354
472
|
continue
|
|
355
|
-
|
|
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)
|
|
356
477
|
if not any(e.kind == "TESTED_BY" for e in tested):
|
|
357
478
|
test_gaps.append({
|
|
358
479
|
"name": _sanitize_name(node.name),
|