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.
Files changed (40) hide show
  1. code_review_graph/__init__.py +1 -1
  2. code_review_graph/changes.py +128 -7
  3. code_review_graph/cli.py +592 -61
  4. code_review_graph/communities.py +235 -21
  5. code_review_graph/config_keys.py +33 -0
  6. code_review_graph/constants.py +51 -1
  7. code_review_graph/css_resolver.py +155 -0
  8. code_review_graph/daemon.py +67 -6
  9. code_review_graph/daemon_cli.py +12 -3
  10. code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +2 -2
  11. code_review_graph/embeddings.py +157 -23
  12. code_review_graph/enrich.py +8 -5
  13. code_review_graph/eval/configs/fastapi.yaml +7 -7
  14. code_review_graph/eval/runner.py +16 -2
  15. code_review_graph/event_resolver.py +122 -0
  16. code_review_graph/exports.py +39 -1
  17. code_review_graph/flows.py +16 -0
  18. code_review_graph/graph.py +567 -243
  19. code_review_graph/hcl_resolver.py +110 -0
  20. code_review_graph/incremental.py +197 -309
  21. code_review_graph/main.py +183 -119
  22. code_review_graph/migrations.py +13 -0
  23. code_review_graph/parser.py +9580 -2760
  24. code_review_graph/postprocessing.py +72 -6
  25. code_review_graph/refactor.py +32 -7
  26. code_review_graph/search.py +19 -0
  27. code_review_graph/skills.py +289 -57
  28. code_review_graph/tools/__init__.py +2 -0
  29. code_review_graph/tools/_common.py +115 -0
  30. code_review_graph/tools/build.py +131 -0
  31. code_review_graph/tools/docs.py +3 -2
  32. code_review_graph/tools/query.py +289 -91
  33. code_review_graph/tools/review.py +0 -8
  34. code_review_graph/uninstall.py +1265 -0
  35. code_review_graph/visualization.py +76 -45
  36. {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/METADATA +58 -24
  37. {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/RECORD +40 -35
  38. {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/WHEEL +0 -0
  39. {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/entry_points.txt +0 -0
  40. {code_review_graph_codeblackwell-2.4.0.dist-info → code_review_graph_codeblackwell-3.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,7 +8,7 @@ from .context_savings import (
8
8
  format_context_savings,
9
9
  )
10
10
 
11
- __version__ = "2.3.6"
11
+ __version__ = "3.0.0"
12
12
 
13
13
  __all__ = [
14
14
  "__version__",
@@ -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. map_changes_to_nodes
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
- # 3. compute_risk_score
308
+ # 4. compute_risk_score
216
309
  # ---------------------------------------------------------------------------
217
310
 
218
311
 
219
- def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
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
- # 4. analyze_changes
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
- # Filter to functions/tests for risk scoring (skip File nodes).
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
- tested = store.get_edges_by_target(node.qualified_name)
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),