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.
- semantic_code_review/__init__.py +5 -0
- semantic_code_review/augment/__init__.py +3 -0
- semantic_code_review/augment/agents.py +95 -0
- semantic_code_review/augment/console.py +440 -0
- semantic_code_review/augment/extra_review.py +237 -0
- semantic_code_review/augment/fold_summary.py +447 -0
- semantic_code_review/augment/hunks.py +264 -0
- semantic_code_review/augment/mcp_server.py +156 -0
- semantic_code_review/augment/overview.py +233 -0
- semantic_code_review/augment/pass_.py +171 -0
- semantic_code_review/augment/pipeline.py +571 -0
- semantic_code_review/augment/progress.py +223 -0
- semantic_code_review/augment/prompts.py +93 -0
- semantic_code_review/augment/schemas.py +408 -0
- semantic_code_review/augment/tools.py +475 -0
- semantic_code_review/augment/trace_adapter.py +369 -0
- semantic_code_review/backends/__init__.py +95 -0
- semantic_code_review/backends/_cli_driver.py +571 -0
- semantic_code_review/backends/anthropic_sdk.py +56 -0
- semantic_code_review/backends/base.py +104 -0
- semantic_code_review/backends/claude_cli.py +421 -0
- semantic_code_review/backends/google_sdk.py +73 -0
- semantic_code_review/backends/openai_compat.py +42 -0
- semantic_code_review/cache/__init__.py +3 -0
- semantic_code_review/cache/store.py +87 -0
- semantic_code_review/cli/__init__.py +70 -0
- semantic_code_review/cli/_shared.py +157 -0
- semantic_code_review/cli/augment.py +74 -0
- semantic_code_review/cli/config_cmd.py +266 -0
- semantic_code_review/cli/fetch.py +33 -0
- semantic_code_review/cli/lint.py +27 -0
- semantic_code_review/cli/pr.py +98 -0
- semantic_code_review/cli/review.py +104 -0
- semantic_code_review/cli/runs_cmd.py +17 -0
- semantic_code_review/cli/show.py +19 -0
- semantic_code_review/cli/strip.py +20 -0
- semantic_code_review/config.py +563 -0
- semantic_code_review/config_template.py +154 -0
- semantic_code_review/fetch/__init__.py +61 -0
- semantic_code_review/fetch/anchor.py +244 -0
- semantic_code_review/fetch/github.py +229 -0
- semantic_code_review/fetch/github_comments.py +404 -0
- semantic_code_review/fetch/local.py +443 -0
- semantic_code_review/fetch/run_source.py +70 -0
- semantic_code_review/format/__init__.py +3 -0
- semantic_code_review/format/emit.py +163 -0
- semantic_code_review/format/lint.py +74 -0
- semantic_code_review/format/parse.py +574 -0
- semantic_code_review/format/sidecar.py +19 -0
- semantic_code_review/format/strip.py +20 -0
- semantic_code_review/git_ops.py +252 -0
- semantic_code_review/paths.py +61 -0
- semantic_code_review/review/__init__.py +3 -0
- semantic_code_review/review/comments.py +176 -0
- semantic_code_review/review/github.py +284 -0
- semantic_code_review/review/github_graphql.py +433 -0
- semantic_code_review/review/pr_flow.py +299 -0
- semantic_code_review/review/runner.py +407 -0
- semantic_code_review/review/server.py +1043 -0
- semantic_code_review/structural/__init__.py +41 -0
- semantic_code_review/structural/diff.py +102 -0
- semantic_code_review/structural/parse.py +330 -0
- semantic_code_review/structural/symbols.py +43 -0
- semantic_code_review/viewer/__init__.py +3 -0
- semantic_code_review/viewer/assets/annotations.ts +547 -0
- semantic_code_review/viewer/assets/boot.ts +273 -0
- semantic_code_review/viewer/assets/comment_store.ts +121 -0
- semantic_code_review/viewer/assets/comments.ts +624 -0
- semantic_code_review/viewer/assets/console.ts +426 -0
- semantic_code_review/viewer/assets/console_render.ts +213 -0
- semantic_code_review/viewer/assets/console_selection.ts +102 -0
- semantic_code_review/viewer/assets/data_store.ts +156 -0
- semantic_code_review/viewer/assets/file_rows.ts +49 -0
- semantic_code_review/viewer/assets/folds.ts +560 -0
- semantic_code_review/viewer/assets/index.html +62 -0
- semantic_code_review/viewer/assets/post_modal.ts +383 -0
- semantic_code_review/viewer/assets/progress.ts +138 -0
- semantic_code_review/viewer/assets/render.ts +937 -0
- semantic_code_review/viewer/assets/sidebar.ts +429 -0
- semantic_code_review/viewer/assets/sse.ts +78 -0
- semantic_code_review/viewer/assets/text_highlight.ts +215 -0
- semantic_code_review/viewer/assets/types.d.ts +388 -0
- semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
- semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
- semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
- semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
- semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
- semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
- semantic_code_review/viewer/assets/viewer.css +1202 -0
- semantic_code_review/viewer/assets/viewer.js +10588 -0
- semantic_code_review/viewer/build_json.py +546 -0
- semantic_code_review/viewer/hunk_layout.py +471 -0
- semantic_code_review-0.24.2.dist-info/METADATA +348 -0
- semantic_code_review-0.24.2.dist-info/RECORD +100 -0
- semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
- semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
- semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
- 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"]
|