semantic-code-review 0.24.2__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 (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,571 @@
1
+ """LLM augmentation pipeline.
2
+
3
+ High-level entry: `augment_run_dir` takes a fetched run directory and
4
+ produces augmented.diff + augmented.scr.json. Uses Claude with tool use
5
+ over the head worktree.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import fnmatch
13
+ import json
14
+ import logging
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from ..cache.store import CacheStore
21
+ from ..format.emit import emit_augmented_diff
22
+ from ..format.parse import parse_raw_diff
23
+ from ..format.sidecar import dump_sidecar
24
+ from ..viewer.build_json import file_fold_spans
25
+ from ..viewer.hunk_layout import build_hunk_viewer_block
26
+ from .agents import Client
27
+ from .hunks import (
28
+ build_hunk_annotations,
29
+ overview_to_prompt_json,
30
+ run_hunk_pass,
31
+ )
32
+ from .overview import apply_overview_to_diff, run_overview_pass
33
+ from .progress import ProgressMeter
34
+ from .schemas import (
35
+ AnnotatedDiff,
36
+ AnnotatedFile,
37
+ AnnotatedHunk,
38
+ FileAnnotations,
39
+ FileRole,
40
+ HunkAnnotations,
41
+ Overview,
42
+ PRInfo,
43
+ lift_file,
44
+ )
45
+ from .tools import RepoTools
46
+
47
+ # Callable signature for streaming progress events. Wired up to the
48
+ # review server's SSE channel by `serve_review`; unset elsewhere
49
+ # (CLI-only augment, tests). Calls are best-effort — pipeline must not
50
+ # fail if the consumer raises.
51
+ OnEvent = Callable[[str, dict[str, Any]], None]
52
+
53
+
54
+ def _safe_emit(on_event: OnEvent | None, event_type: str, payload: dict[str, Any]) -> None:
55
+ if on_event is None:
56
+ return
57
+ try:
58
+ on_event(event_type, payload)
59
+ except Exception:
60
+ log.exception("on_event consumer raised for %s; continuing", event_type)
61
+
62
+
63
+ # Paths we do not send to the LLM — lock files, vendored bundles, binary
64
+ # formats. The hunks still appear in the viewer; they just lack annotations.
65
+ DEFAULT_SKIP_GLOBS: tuple[str, ...] = (
66
+ "*.lock",
67
+ "*.min.js",
68
+ "*.min.css",
69
+ "package-lock.json",
70
+ "pnpm-lock.yaml",
71
+ "yarn.lock",
72
+ "Pipfile.lock",
73
+ "poetry.lock",
74
+ "uv.lock",
75
+ "*.png",
76
+ "*.jpg",
77
+ "*.jpeg",
78
+ "*.gif",
79
+ "*.svg",
80
+ "*.ico",
81
+ "*.woff",
82
+ "*.woff2",
83
+ "*.ttf",
84
+ "*.otf",
85
+ "*.pdf",
86
+ )
87
+
88
+
89
+ def _should_skip(path: str, extra_globs: tuple[str, ...] = ()) -> bool:
90
+ globs = DEFAULT_SKIP_GLOBS + tuple(extra_globs)
91
+ name = path.rsplit("/", 1)[-1]
92
+ return any(fnmatch.fnmatch(path, g) or fnmatch.fnmatch(name, g) for g in globs)
93
+
94
+
95
+ log = logging.getLogger(__name__)
96
+
97
+
98
+ async def augment_run_dir(
99
+ run_dir: Path,
100
+ *,
101
+ model: str = "claude-opus-4-7",
102
+ concurrency: int = 8,
103
+ client: Client | None = None,
104
+ cache: CacheStore | None = None,
105
+ only_files: list[str] | None = None,
106
+ max_hunks: int | None = None,
107
+ skip_overview: bool = False,
108
+ skip_context: bool = False,
109
+ extra_review_prompt: str | None = None,
110
+ show_progress: bool = True,
111
+ on_event: OnEvent | None = None,
112
+ ) -> Path:
113
+ """Augment a fetch run directory. Returns the augmented.diff path."""
114
+ if client is None:
115
+ # Default to the Anthropic SDK path via pydantic-ai. Callers that
116
+ # need a different backend (CLI, Gemini, tests) construct the
117
+ # backend explicitly via `_select_client` or a stub.
118
+ client = Client(model=f"anthropic:{model}")
119
+ # cache=None means "no disk caching"; callers pass a CacheStore to enable.
120
+
121
+ raw_diff_path = run_dir / "raw.diff"
122
+ meta_path = run_dir / "meta.json"
123
+ augmented_path = run_dir / "augmented.diff"
124
+ sidecar_path = run_dir / "augmented.scr.json"
125
+
126
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
127
+ raw = raw_diff_path.read_text(encoding="utf-8")
128
+ parsed = parse_raw_diff(raw)
129
+ pr = PRInfo(
130
+ pr_url=meta.get("url", ""),
131
+ base_sha=meta.get("baseRefOid", ""),
132
+ head_sha=meta.get("headRefOid", ""),
133
+ model=model,
134
+ )
135
+ parsed_files = parsed.files
136
+ if only_files:
137
+ parsed_files = [f for f in parsed_files if f.path in only_files]
138
+
139
+ # Lift to AnnotatedDiff with empty annotations. Skipped (lock / binary)
140
+ # files get their FileAnnotations pre-populated so that downstream
141
+ # passes leave them alone and the viewer renders the right label.
142
+ skipped_files: set[str] = set()
143
+ diff_files: list[AnnotatedFile] = []
144
+ for pfile in parsed_files:
145
+ if _should_skip(pfile.path):
146
+ ann = FileAnnotations(role=FileRole.GENERATED, summary="Generated / lock file — not analysed.")
147
+ skipped_files.add(pfile.path)
148
+ else:
149
+ ann = FileAnnotations()
150
+ diff_files.append(lift_file(pfile, ann=ann))
151
+ diff = AnnotatedDiff(version=parsed.version, pr=pr, files=diff_files)
152
+
153
+ if skipped_files:
154
+ log.info("skipping %d generated file(s): %s", len(skipped_files), ", ".join(sorted(skipped_files)))
155
+
156
+ trace_dir = run_dir / "trace"
157
+ trace_dir.mkdir(parents=True, exist_ok=True)
158
+ _attach_file_log(trace_dir / "augment.log")
159
+
160
+ # Enumerate hunks ahead of dispatch so we know the total up front
161
+ # (the progress meter wants this; the dispatch loop wants ordinal
162
+ # indices to attribute start/finish events to the right square).
163
+ queued: list[tuple[int, int, int]] = [] # (file_idx, hunk_idx, ordinal)
164
+ for fi, fp in enumerate(diff.files):
165
+ if fp.path in skipped_files:
166
+ continue
167
+ for hi in range(len(fp.hunks)):
168
+ if max_hunks is not None and len(queued) >= max_hunks:
169
+ break
170
+ queued.append((fi, hi, len(queued)))
171
+ if max_hunks is not None and len(queued) >= max_hunks:
172
+ break
173
+
174
+ # show_progress=False → meter is a no-op even on a truecolor TTY
175
+ # (caller is in --verbose mode, where the redraw line would fight
176
+ # the log stream). show_progress=True → meter still gates on TTY +
177
+ # truecolor advertising before drawing anything.
178
+ meter = ProgressMeter(
179
+ total=len(queued),
180
+ enabled=None if show_progress else False,
181
+ )
182
+
183
+ # Deterministic structural symbol delta (ADR 0001 Slice 3). Computed
184
+ # from our own tree-sitter parse of base vs head — independent of
185
+ # `skip_context`, which only gates the LLM's per-hunk tool access, not
186
+ # this in-process seed. Best-effort: a failure leaves the overview
187
+ # unseeded (today's behaviour) rather than aborting the run.
188
+ symbol_delta = None
189
+ try:
190
+ symbol_delta = RepoTools(
191
+ head_worktree=run_dir / "head",
192
+ repo_git=run_dir / "repo.git",
193
+ base_sha=diff.pr.base_sha,
194
+ head_sha=diff.pr.head_sha,
195
+ ).compute_symbol_delta()
196
+ except Exception: # noqa: BLE001 — seed is best-effort
197
+ log.warning("structural symbol seed failed; overview runs unseeded", exc_info=True)
198
+
199
+ async with meter:
200
+ # --- Overview pass -------------------------------------------------
201
+ if not skip_overview:
202
+ log.info("overview pass for %d files", len(diff.files))
203
+ meter.start_overview()
204
+ _safe_emit(on_event, "overview-start", {})
205
+ try:
206
+ ov = await run_overview_pass(
207
+ client,
208
+ diff=diff,
209
+ meta=meta,
210
+ model=model,
211
+ delta=symbol_delta,
212
+ cache=cache,
213
+ trace_dir=trace_dir,
214
+ )
215
+ diff = apply_overview_to_diff(diff, ov)
216
+ meter.finish_overview(ok=True)
217
+ _safe_emit(on_event, "overview", _overview_event_payload(diff))
218
+ except Exception:
219
+ meter.finish_overview(ok=False)
220
+ _safe_emit(on_event, "overview-failed", {})
221
+ raise
222
+
223
+ # --- Per-hunk pass -------------------------------------------------
224
+ repo_tools = (
225
+ RepoTools(
226
+ head_worktree=run_dir / "head",
227
+ repo_git=run_dir / "repo.git",
228
+ base_sha=diff.pr.base_sha,
229
+ head_sha=diff.pr.head_sha,
230
+ )
231
+ if not skip_context
232
+ else None
233
+ )
234
+
235
+ # CLI subprocess backends use this to spawn an MCP server bound
236
+ # to the run's worktree. SDK backends are no-ops; the SDK Agent
237
+ # gets `deps=repo_tools` directly via `Agent.run` in `run_hunk_pass`.
238
+ client.set_repo_tools(repo_tools)
239
+
240
+ overview_json = overview_to_prompt_json(diff)
241
+
242
+ # Per-file definition spans, parsed once from the worktrees, so the
243
+ # per-hunk SSE re-emits below carry symbol-aware `fold_regions`
244
+ # addresses in lockstep with the full-page build and the viewer's
245
+ # client-side detector. Empty lists where a worktree is absent.
246
+ head_dir = run_dir / "head"
247
+ base_dir = run_dir / "base"
248
+ file_spans: dict[int, tuple[list, list]] = {
249
+ fi: file_fold_spans(
250
+ fp,
251
+ base_dir if base_dir.exists() else None,
252
+ head_dir if head_dir.exists() else None,
253
+ )
254
+ for fi, fp in enumerate(diff.files)
255
+ }
256
+
257
+ # Subprocess clients allocate temp config files at first use;
258
+ # `aclosing` calls `client.aclose()` on exit so /tmp doesn't
259
+ # accumulate them across runs. SDKBackend's aclose is a no-op
260
+ # so this is uniform across backends.
261
+ async with contextlib.aclosing(client):
262
+ sem = asyncio.Semaphore(concurrency)
263
+ stats = _HunkStats()
264
+ results: dict[tuple[int, int], HunkAnnotations] = {}
265
+ tasks = [
266
+ asyncio.create_task(
267
+ _augment_one_hunk(
268
+ ord_idx,
269
+ meter,
270
+ sem,
271
+ client,
272
+ diff,
273
+ fi,
274
+ hi,
275
+ overview_json,
276
+ repo_tools,
277
+ model,
278
+ cache,
279
+ trace_dir,
280
+ stats,
281
+ results,
282
+ on_event,
283
+ file_spans.get(fi, ([], [])),
284
+ )
285
+ )
286
+ for fi, hi, ord_idx in queued
287
+ ]
288
+
289
+ log.info("per-hunk pass: %d hunks queued (concurrency=%d)", len(tasks), concurrency)
290
+ await asyncio.gather(*tasks)
291
+
292
+ # Merge per-hunk results back into the diff in one pass.
293
+ diff = _merge_hunk_results(diff, results)
294
+
295
+ # --- PR-level extra-review pass (opt-in) -------------------------
296
+ # Runs once over the whole diff so the user's prompt can catch
297
+ # cross-file concerns (schema migrations, missing tests, design
298
+ # consistency) that a per-hunk view fundamentally can't see.
299
+ # Best-effort: any failure leaves `diff` unchanged and logs.
300
+ if extra_review_prompt:
301
+ from .extra_review import run_pr_level_extra_review
302
+
303
+ diff_before = diff
304
+ diff = await run_pr_level_extra_review(
305
+ client,
306
+ diff=diff,
307
+ overview_json=overview_json,
308
+ diff_text=raw,
309
+ prompt_text=extra_review_prompt,
310
+ model=model,
311
+ cache=cache,
312
+ trace_dir=trace_dir,
313
+ )
314
+ # Re-emit hunk SSE events for hunks whose line_notes grew.
315
+ # The streaming viewer already rendered the per-hunk blocks
316
+ # without extras; this pushes the augmented bodies so the
317
+ # promote-to-comment affordance lights up on the new notes
318
+ # without the user needing to refresh.
319
+ for fi, fp in enumerate(diff.files):
320
+ if fi >= len(diff_before.files):
321
+ continue
322
+ old_fp = diff_before.files[fi]
323
+ for hi, hunk in enumerate(fp.hunks):
324
+ if hi >= len(old_fp.hunks):
325
+ continue
326
+ if len(hunk.ann.line_notes) == len(old_fp.hunks[hi].ann.line_notes):
327
+ continue
328
+ block = build_hunk_viewer_block(
329
+ hunk,
330
+ fi,
331
+ hi,
332
+ *file_spans.get(fi, ([], [])),
333
+ )
334
+ _safe_emit(
335
+ on_event,
336
+ "hunk",
337
+ {
338
+ "file_idx": fi,
339
+ "hunk_idx": hi,
340
+ "ok": True,
341
+ "block": block,
342
+ },
343
+ )
344
+
345
+ # --- Emit ----------------------------------------------------------
346
+ augmented_text = emit_augmented_diff(diff)
347
+ augmented_path.write_text(augmented_text, encoding="utf-8")
348
+ dump_sidecar(diff, sidecar_path)
349
+ log.info("wrote %s (%d bytes) + sidecar", augmented_path.name, len(augmented_text))
350
+
351
+ # After the meter has finished its final repaint and dropped to a
352
+ # fresh line, emit the human-readable summary to stderr so the
353
+ # one-liner doesn't fight the meter's redraw window.
354
+ backend_tag = "subprocess" if client.is_subprocess_backend else "sdk"
355
+ summary = f"scr augment: backend={backend_tag} model={model} hunks={len(tasks)} ok={stats.ok} failed={stats.failed}"
356
+ log.info(summary)
357
+ import sys as _sys
358
+
359
+ _sys.stderr.write(summary + "\n")
360
+ _sys.stderr.flush()
361
+
362
+ if stats.failed and stats.ok == 0:
363
+ log.error(
364
+ "augmentation produced ZERO annotations: all %d hunks failed. "
365
+ "See per-hunk warnings and trace files under %s. "
366
+ "Common cause in --backend=claude-cli: `claude -p` not logged in or "
367
+ "refused to emit structured JSON within --max-turns.",
368
+ stats.failed,
369
+ trace_dir,
370
+ )
371
+ return augmented_path
372
+
373
+
374
+ @dataclass
375
+ class _HunkStats:
376
+ ok: int = 0
377
+ failed: int = 0
378
+
379
+
380
+ async def _augment_one_hunk(
381
+ ord_idx: int,
382
+ meter: ProgressMeter,
383
+ sem: asyncio.Semaphore,
384
+ client: Client,
385
+ diff: AnnotatedDiff,
386
+ fi: int,
387
+ hi: int,
388
+ overview_json: str,
389
+ repo_tools: RepoTools | None,
390
+ model: str,
391
+ cache: CacheStore | None,
392
+ trace_dir: Path,
393
+ stats: _HunkStats,
394
+ results: dict[tuple[int, int], HunkAnnotations],
395
+ on_event: OnEvent | None,
396
+ fold_spans: tuple[list, list],
397
+ ) -> None:
398
+ fp = diff.files[fi]
399
+ hunk = fp.hunks[hi]
400
+ file_summary = (fp.ann.summary or "").strip()
401
+ async with sem:
402
+ # Mark the square live only AFTER acquiring the semaphore so
403
+ # queued-but-unstarted hunks still render as pending dots.
404
+ meter.start_hunk(ord_idx)
405
+ _safe_emit(on_event, "hunk-start", {"file_idx": fi, "hunk_idx": hi})
406
+ try:
407
+ if repo_tools is None:
408
+ rt = RepoTools(
409
+ head_worktree=Path("/dev/null"),
410
+ repo_git=Path("/dev/null"),
411
+ base_sha="",
412
+ head_sha="",
413
+ )
414
+ else:
415
+ rt = repo_tools
416
+ submit = await run_hunk_pass(
417
+ client,
418
+ fp=fp,
419
+ hunk=hunk,
420
+ overview_json=overview_json,
421
+ file_summary=file_summary,
422
+ repo_tools=rt,
423
+ model=model,
424
+ cache=cache,
425
+ trace_dir=trace_dir,
426
+ )
427
+ ann = build_hunk_annotations(hunk.parsed, submit)
428
+ results[(fi, hi)] = ann
429
+ stats.ok += 1
430
+ meter.finish_hunk(ord_idx, ok=True)
431
+ log.info(
432
+ "hunk %s @ %s: intent=%r smells=%d segs=%d notes=%d",
433
+ fp.path,
434
+ hunk.parsed.header,
435
+ (ann.intent or "")[:80],
436
+ len(ann.smells),
437
+ len(ann.segments),
438
+ len(ann.line_notes),
439
+ )
440
+ block = build_hunk_viewer_block(
441
+ AnnotatedHunk(parsed=hunk.parsed, ann=ann),
442
+ fi,
443
+ hi,
444
+ fold_spans[0],
445
+ fold_spans[1],
446
+ )
447
+ _safe_emit(
448
+ on_event,
449
+ "hunk",
450
+ {
451
+ "file_idx": fi,
452
+ "hunk_idx": hi,
453
+ "ok": True,
454
+ "block": block,
455
+ },
456
+ )
457
+ except Exception as e: # noqa: BLE001
458
+ stats.failed += 1
459
+ meter.finish_hunk(ord_idx, ok=False)
460
+ log.warning(
461
+ "hunk %s @ %s failed: %s: %s",
462
+ fp.path,
463
+ hunk.parsed.header,
464
+ type(e).__name__,
465
+ e,
466
+ )
467
+ _safe_emit(
468
+ on_event,
469
+ "hunk",
470
+ {
471
+ "file_idx": fi,
472
+ "hunk_idx": hi,
473
+ "ok": False,
474
+ "error": f"{type(e).__name__}: {e}",
475
+ },
476
+ )
477
+
478
+
479
+ def _overview_event_payload(diff: AnnotatedDiff) -> dict[str, Any]:
480
+ """Build the `overview` SSE payload from the post-overview diff.
481
+
482
+ Carries the PR-level fields the viewer wants to update (summary,
483
+ themes, symbols, callgraph), the semantic groups the sidebar
484
+ filters by, and per-file summaries/symbols that show up in the
485
+ file header. Mirrors the relevant slices of `build_viewer_json`'s
486
+ output so the viewer can patch in place without re-fetching
487
+ `data.json`.
488
+ """
489
+ ov = diff.overview if isinstance(diff.overview, Overview) else None
490
+ path_to_file_idx = {fp.path: i for i, fp in enumerate(diff.files)}
491
+ groups: list[dict[str, Any]] = []
492
+ if ov is not None:
493
+ for gi, g in enumerate(ov.groups):
494
+ hunk_ids: list[str] = []
495
+ for m in g.members:
496
+ fi = path_to_file_idx.get(m.path)
497
+ if fi is None:
498
+ continue
499
+ hunk_ids.append(f"H{fi}_{m.hunk_index}")
500
+ if not hunk_ids:
501
+ continue
502
+ groups.append(
503
+ {
504
+ "id": f"G{gi}",
505
+ "title": g.title,
506
+ "rationale": g.rationale,
507
+ "hunk_ids": hunk_ids,
508
+ }
509
+ )
510
+ file_patches = [
511
+ {
512
+ "file_idx": i,
513
+ "path": fp.path,
514
+ "summary": fp.ann.summary,
515
+ "language": fp.ann.lang or "",
516
+ "symbols": (
517
+ fp.ann.symbols.model_dump() if fp.ann.symbols else {"added": [], "modified": [], "removed": []}
518
+ ),
519
+ "status": fp.ann.role.value if fp.ann.role else "modified",
520
+ }
521
+ for i, fp in enumerate(diff.files)
522
+ ]
523
+ return {
524
+ "pr": {
525
+ "summary": ov.summary if ov else "",
526
+ "themes": ov.themes if ov else [],
527
+ "symbols_added": [s.model_dump() for s in (ov.symbols_added if ov else [])],
528
+ "symbols_modified": [s.model_dump() for s in (ov.symbols_modified if ov else [])],
529
+ "symbols_removed": [s.model_dump() for s in (ov.symbols_removed if ov else [])],
530
+ "callgraph_edges": [e.model_dump(by_alias=True) for e in (ov.callgraph_edges if ov else [])],
531
+ },
532
+ "groups": groups,
533
+ "files": file_patches,
534
+ }
535
+
536
+
537
+ def _merge_hunk_results(
538
+ diff: AnnotatedDiff,
539
+ results: dict[tuple[int, int], HunkAnnotations],
540
+ ) -> AnnotatedDiff:
541
+ if not results:
542
+ return diff
543
+ new_files: list[AnnotatedFile] = []
544
+ for fi, fp in enumerate(diff.files):
545
+ new_hunks: list[AnnotatedHunk] = []
546
+ for hi, h in enumerate(fp.hunks):
547
+ ann = results.get((fi, hi))
548
+ if ann is None:
549
+ new_hunks.append(h)
550
+ else:
551
+ new_hunks.append(h.model_copy(update={"ann": ann}))
552
+ new_files.append(fp.model_copy(update={"hunks": new_hunks}))
553
+ return diff.model_copy(update={"files": new_files})
554
+
555
+
556
+ def _attach_file_log(path: Path) -> None:
557
+ """Route `semantic_code_review.*` INFO+ log records to `path`."""
558
+ root = logging.getLogger("semantic_code_review")
559
+ # Idempotent: replace any previous FileHandler for a different run.
560
+ for existing in list(root.handlers):
561
+ if isinstance(existing, logging.FileHandler):
562
+ root.removeHandler(existing)
563
+ handler = logging.FileHandler(path, mode="w", encoding="utf-8")
564
+ handler.setLevel(logging.INFO)
565
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
566
+ root.addHandler(handler)
567
+ if root.level == 0 or root.level > logging.INFO:
568
+ root.setLevel(logging.INFO)
569
+
570
+
571
+ __all__ = ["augment_run_dir"]