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,237 @@
|
|
|
1
|
+
"""Additional whole-PR review pass driven by a user-supplied prompt.
|
|
2
|
+
|
|
3
|
+
The main per-hunk pass is comprehension-first — it answers "what does
|
|
4
|
+
this change do?" and only secondarily flags concerns. Teams often
|
|
5
|
+
want an *additional* pass with a different brief: bug-hunting,
|
|
6
|
+
security review, style checks, schema-migration audits. Folding
|
|
7
|
+
those instructions into HUNK_SYSTEM would dilute the comprehension
|
|
8
|
+
role and force every observation to fit the closed `SMELL_TAGS`
|
|
9
|
+
vocabulary, and a per-hunk view fundamentally can't see cross-file
|
|
10
|
+
concerns like "added a new persistence format with no schema version"
|
|
11
|
+
or "added logic but no tests".
|
|
12
|
+
|
|
13
|
+
So the extra pass runs *once per PR*, after the overview + per-hunk
|
|
14
|
+
passes have completed. The model sees the user's prompt as the
|
|
15
|
+
system message, the PR overview JSON, and the raw unified diff. It
|
|
16
|
+
returns a flat list of ``(file, line, body)`` line-anchored notes;
|
|
17
|
+
the pipeline buckets each one into the matching hunk's ``line_notes``
|
|
18
|
+
so the viewer renders them alongside main-pass annotations. The
|
|
19
|
+
reviewer can promote any of them to a PR comment via the existing
|
|
20
|
+
"Add as comment" affordance.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
from pydantic_ai import Agent, CachePoint
|
|
31
|
+
from pydantic_ai.messages import UserContent
|
|
32
|
+
from pydantic_ai.models import Model
|
|
33
|
+
from pydantic_ai.output import ToolOutput
|
|
34
|
+
|
|
35
|
+
from ..cache.store import CacheStore
|
|
36
|
+
from .agents import Client
|
|
37
|
+
from .pass_ import PassMeta, run_pass
|
|
38
|
+
from .schemas import AnnotatedDiff, LineNote
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Two breakpoints — instructions (the user prompt) and after the
|
|
44
|
+
# overview JSON — so a re-run of the same PR with the same prompt
|
|
45
|
+
# but a different diff still reads the prefix from cache.
|
|
46
|
+
_EXTRA_CACHE_SETTINGS: dict[str, Any] = {
|
|
47
|
+
"anthropic_cache_instructions": True,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Best-effort pass: a failure here returns the original diff unchanged
|
|
52
|
+
# rather than poisoning the main-pass output. `swallow_errors=True`
|
|
53
|
+
# makes `run_pass` log + return None on exception; the caller then
|
|
54
|
+
# short-circuits to the unmodified diff.
|
|
55
|
+
_EXTRA_REVIEW = PassMeta(
|
|
56
|
+
name="extra-review-pr",
|
|
57
|
+
submit_tool="submit_extra_notes",
|
|
58
|
+
swallow_errors=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExtraReviewNote(BaseModel):
|
|
63
|
+
"""One line-anchored observation produced by the PR-level extra-review pass."""
|
|
64
|
+
|
|
65
|
+
file: str = Field(description="Repository-relative path of the file the note applies to.")
|
|
66
|
+
line: int = Field(description="Post-image (new-side) line number this note applies to.")
|
|
67
|
+
body: str = Field(
|
|
68
|
+
description=(
|
|
69
|
+
"The reviewer-facing note. Plain text or GitHub-flavored Markdown — "
|
|
70
|
+
"the body becomes the PR comment verbatim if the reviewer promotes it."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ExtraReviewSubmission(BaseModel):
|
|
76
|
+
notes: list[ExtraReviewNote] = Field(
|
|
77
|
+
default_factory=list,
|
|
78
|
+
description=(
|
|
79
|
+
"Line-anchored observations across the whole diff. Each note's "
|
|
80
|
+
"(file, line) must point at a post-image line that exists inside "
|
|
81
|
+
"one of the diff's hunks. Emit only what the prompt actually "
|
|
82
|
+
"finds; an empty list is fine."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_extra_review_agent(
|
|
88
|
+
model: str | Model,
|
|
89
|
+
system_prompt: str,
|
|
90
|
+
) -> Agent[None, ExtraReviewSubmission]:
|
|
91
|
+
"""Agent for the PR-level extra-review pass.
|
|
92
|
+
|
|
93
|
+
The user-supplied prompt becomes the system message. Output is
|
|
94
|
+
constrained via ``ToolOutput(ExtraReviewSubmission, name='submit_extra_notes')``;
|
|
95
|
+
no repo tools are registered — the call works from the same
|
|
96
|
+
pre-shaped context (overview + raw diff text) that the rest of
|
|
97
|
+
the pipeline assembles.
|
|
98
|
+
"""
|
|
99
|
+
return Agent(
|
|
100
|
+
model=model,
|
|
101
|
+
output_type=ToolOutput(ExtraReviewSubmission, name="submit_extra_notes"),
|
|
102
|
+
instructions=system_prompt,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_pr_level_prompt(
|
|
107
|
+
*,
|
|
108
|
+
overview_json: str,
|
|
109
|
+
diff_text: str,
|
|
110
|
+
) -> list[UserContent]:
|
|
111
|
+
return [
|
|
112
|
+
f"# PR overview\n{overview_json}",
|
|
113
|
+
CachePoint(),
|
|
114
|
+
f"# Diff\n{diff_text}",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _distribute_notes_to_hunks(
|
|
119
|
+
diff: AnnotatedDiff,
|
|
120
|
+
notes: list[ExtraReviewNote],
|
|
121
|
+
) -> AnnotatedDiff:
|
|
122
|
+
"""Bucket each ``(file, line)`` note into the matching hunk's
|
|
123
|
+
``line_notes``. Notes whose ``file`` doesn't match any AnnotatedFile,
|
|
124
|
+
or whose ``line`` falls outside every hunk's post-image range, get
|
|
125
|
+
dropped with a warning — the model isn't trusted to stay in bounds.
|
|
126
|
+
Returns a new AnnotatedDiff; the input is not mutated.
|
|
127
|
+
"""
|
|
128
|
+
by_path = {fp.path: fp for fp in diff.files}
|
|
129
|
+
list(diff.files)
|
|
130
|
+
# Per-hunk new_notes accumulators keyed by (file_idx, hunk_idx).
|
|
131
|
+
appends: dict[tuple[int, int], list[LineNote]] = {}
|
|
132
|
+
|
|
133
|
+
for n in notes:
|
|
134
|
+
body = (n.body or "").strip()
|
|
135
|
+
if not body:
|
|
136
|
+
continue
|
|
137
|
+
fp = by_path.get(n.file)
|
|
138
|
+
if fp is None:
|
|
139
|
+
log.warning(
|
|
140
|
+
"extra-review note for unknown path %r — dropped (body=%r)",
|
|
141
|
+
n.file,
|
|
142
|
+
body[:80],
|
|
143
|
+
)
|
|
144
|
+
continue
|
|
145
|
+
fi = diff.files.index(fp)
|
|
146
|
+
landed = False
|
|
147
|
+
for hi, hunk in enumerate(fp.hunks):
|
|
148
|
+
start = hunk.parsed.new_start
|
|
149
|
+
end = start + hunk.parsed.new_count - 1
|
|
150
|
+
if start <= n.line <= end:
|
|
151
|
+
appends.setdefault((fi, hi), []).append(
|
|
152
|
+
LineNote(line=n.line, body=body),
|
|
153
|
+
)
|
|
154
|
+
landed = True
|
|
155
|
+
break
|
|
156
|
+
if not landed:
|
|
157
|
+
log.warning(
|
|
158
|
+
"extra-review note %s:%d outside any hunk's range — dropped (body=%r)",
|
|
159
|
+
n.file,
|
|
160
|
+
n.line,
|
|
161
|
+
body[:80],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not appends:
|
|
165
|
+
return diff
|
|
166
|
+
# Rebuild only the files that gained notes.
|
|
167
|
+
new_files = list(diff.files)
|
|
168
|
+
files_touched: set[int] = {fi for fi, _ in appends}
|
|
169
|
+
for fi in files_touched:
|
|
170
|
+
fp = new_files[fi]
|
|
171
|
+
new_hunks = list(fp.hunks)
|
|
172
|
+
for (afi, hi), to_add in appends.items():
|
|
173
|
+
if afi != fi:
|
|
174
|
+
continue
|
|
175
|
+
h = new_hunks[hi]
|
|
176
|
+
new_ann = h.ann.model_copy(
|
|
177
|
+
update={"line_notes": list(h.ann.line_notes) + to_add},
|
|
178
|
+
)
|
|
179
|
+
new_hunks[hi] = h.model_copy(update={"ann": new_ann})
|
|
180
|
+
new_files[fi] = fp.model_copy(update={"hunks": new_hunks})
|
|
181
|
+
return diff.model_copy(update={"files": new_files})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def run_pr_level_extra_review(
|
|
185
|
+
client: Client,
|
|
186
|
+
*,
|
|
187
|
+
diff: AnnotatedDiff,
|
|
188
|
+
overview_json: str,
|
|
189
|
+
diff_text: str,
|
|
190
|
+
prompt_text: str,
|
|
191
|
+
model: str,
|
|
192
|
+
cache: CacheStore | None = None,
|
|
193
|
+
trace_dir: Path | None = None,
|
|
194
|
+
) -> AnnotatedDiff:
|
|
195
|
+
"""Run the PR-level extra-review call and return a copy of ``diff``
|
|
196
|
+
with the resulting notes folded into the matching hunks'
|
|
197
|
+
``line_notes``.
|
|
198
|
+
|
|
199
|
+
Best-effort: any failure (model error, schema mismatch after
|
|
200
|
+
retries, etc.) leaves ``diff`` unchanged and logs a warning. The
|
|
201
|
+
extra pass is an add-on; the main-pass output is load-bearing
|
|
202
|
+
and must not be poisoned by an extras hiccup.
|
|
203
|
+
"""
|
|
204
|
+
payload = await run_pass(
|
|
205
|
+
_EXTRA_REVIEW,
|
|
206
|
+
client=client,
|
|
207
|
+
agent=make_extra_review_agent(client.model, system_prompt=prompt_text),
|
|
208
|
+
user_content=_format_pr_level_prompt(
|
|
209
|
+
overview_json=overview_json,
|
|
210
|
+
diff_text=diff_text,
|
|
211
|
+
),
|
|
212
|
+
# `prompt_text` is the user-supplied system prompt — it varies
|
|
213
|
+
# per call, so it lives on the run_pass arg surface rather than
|
|
214
|
+
# on PassMeta. It also participates in the cache key (via the
|
|
215
|
+
# `system` parameter) so re-runs with a different prompt miss.
|
|
216
|
+
system=prompt_text,
|
|
217
|
+
model=model,
|
|
218
|
+
cache_inputs=(overview_json, diff_text),
|
|
219
|
+
model_settings=_EXTRA_CACHE_SETTINGS,
|
|
220
|
+
cache=cache,
|
|
221
|
+
trace_path=(trace_dir / "extra-review.json") if trace_dir is not None else None,
|
|
222
|
+
cache_request={"diff_len": len(diff_text)},
|
|
223
|
+
)
|
|
224
|
+
if payload is None:
|
|
225
|
+
return diff
|
|
226
|
+
|
|
227
|
+
notes = [ExtraReviewNote.model_validate(n) for n in payload.get("notes") or []]
|
|
228
|
+
log.info("extra-review: %d notes emitted across %d files", len(notes), len({n.file for n in notes}))
|
|
229
|
+
return _distribute_notes_to_hunks(diff, notes)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
__all__ = [
|
|
233
|
+
"ExtraReviewNote",
|
|
234
|
+
"ExtraReviewSubmission",
|
|
235
|
+
"make_extra_review_agent",
|
|
236
|
+
"run_pr_level_extra_review",
|
|
237
|
+
]
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""On-demand fold-region summariser.
|
|
2
|
+
|
|
3
|
+
The per-hunk LLM pass used to ask the model for a one-liner per indent
|
|
4
|
+
fold region. Most folds are never collapsed in a review, so we now
|
|
5
|
+
defer: the review server fires this code path the first time the
|
|
6
|
+
reviewer closes a region, the result is cached, and the augmented
|
|
7
|
+
sidecar is updated so subsequent loads are free.
|
|
8
|
+
|
|
9
|
+
Address space (slice 1 of "fold anywhere"): a fold region is identified
|
|
10
|
+
by a file path + a `context` (right / left / both) + 1-indexed line
|
|
11
|
+
ranges into the named worktree file. Pure-context folds use right;
|
|
12
|
+
deletion-only folds use left; folds that straddle changed content use
|
|
13
|
+
both. The server reads the actual line content from the head/ and/or
|
|
14
|
+
base/ worktrees and passes a prepared body string to this module —
|
|
15
|
+
the summariser stays narrow and focused on the LLM call + caching.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import difflib
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
from pydantic_ai import Agent, CachePoint
|
|
27
|
+
from pydantic_ai.messages import UserContent
|
|
28
|
+
from pydantic_ai.models import Model
|
|
29
|
+
from pydantic_ai.output import ToolOutput
|
|
30
|
+
|
|
31
|
+
from ..cache.store import CacheStore
|
|
32
|
+
from .agents import Client
|
|
33
|
+
from .pass_ import PassMeta, run_pass
|
|
34
|
+
from .schemas import AnnotatedDiff, FoldDescription
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_FOLD = PassMeta(name="fold-summary-v2", submit_tool="submit_fold_summary")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
FoldContext = Literal["right", "left", "both"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
FOLD_SYSTEM = (
|
|
46
|
+
"You are describing one COLLAPSED fold region for a reviewer who has "
|
|
47
|
+
"the body hidden; your sentence is the ONLY thing they see in place of it.\n\n"
|
|
48
|
+
"The user prompt declares which kind of region you're looking at:\n"
|
|
49
|
+
" - `right`: the lines exist post-change. Describe what the code DOES.\n"
|
|
50
|
+
" - `left`: the lines are being REMOVED. Describe what they did,\n"
|
|
51
|
+
" phrased so the reviewer understands what was lost.\n"
|
|
52
|
+
" - `both`: the fold straddles changed content; you'll see a diff.\n"
|
|
53
|
+
" Describe the CHANGE the fold introduces.\n\n"
|
|
54
|
+
"Rules:\n"
|
|
55
|
+
"- One sentence. <= 25 words.\n"
|
|
56
|
+
"- Present tense. Lowercase. No trailing period.\n"
|
|
57
|
+
"- Describe EFFECT, not structure or control flow.\n"
|
|
58
|
+
"- Tone: explanatory, not evaluative.\n\n"
|
|
59
|
+
"Good: 'convert every top-level message in every descriptor to a json schema'.\n"
|
|
60
|
+
"Good: 'fall back to inline generation when include_all is set'.\n"
|
|
61
|
+
"Good: 'forward page/size kwargs to list_users so pagination reaches the handler'.\n"
|
|
62
|
+
"Bad: 'iterate every file descriptor in the set' (structure, not effect).\n"
|
|
63
|
+
"Bad: 'if / elif / else' (control flow).\n"
|
|
64
|
+
"Bad: 'call self._convert_message_to_schema' (names a call, not what is achieved)."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FoldSummarySubmission(BaseModel):
|
|
69
|
+
"""Wire format for the `submit_fold_summary` tool. Single field, kept
|
|
70
|
+
structured so trace + cache plumbing matches the other passes.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
summary: str = Field(description="One sentence describing the folded region's effect.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_fold_summary_agent(model: str | Model) -> Agent[None, FoldSummarySubmission]:
|
|
77
|
+
return Agent(
|
|
78
|
+
model=model,
|
|
79
|
+
output_type=ToolOutput(FoldSummarySubmission, name="submit_fold_summary"),
|
|
80
|
+
instructions=FOLD_SYSTEM,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_fold_body(
|
|
85
|
+
run_dir: Path,
|
|
86
|
+
file_path: str,
|
|
87
|
+
context: FoldContext,
|
|
88
|
+
right_range: tuple[int, int] | None,
|
|
89
|
+
left_range: tuple[int, int] | None,
|
|
90
|
+
) -> str:
|
|
91
|
+
"""Read the actual line content for a fold region.
|
|
92
|
+
|
|
93
|
+
`right_range` / `left_range` are 1-indexed inclusive `(start, end)`.
|
|
94
|
+
For `right` / `left` returns the plain lines joined with newlines.
|
|
95
|
+
For `both` returns a unified-diff-style body so the LLM can see
|
|
96
|
+
what changed.
|
|
97
|
+
"""
|
|
98
|
+
head_path = run_dir / "head" / file_path
|
|
99
|
+
base_path = run_dir / "base" / file_path
|
|
100
|
+
|
|
101
|
+
if context == "right":
|
|
102
|
+
if right_range is None:
|
|
103
|
+
return ""
|
|
104
|
+
return "\n".join(_slice_lines(head_path, *right_range))
|
|
105
|
+
if context == "left":
|
|
106
|
+
if left_range is None:
|
|
107
|
+
return ""
|
|
108
|
+
return "\n".join(_slice_lines(base_path, *left_range))
|
|
109
|
+
# both — unified diff between the corresponding slices.
|
|
110
|
+
right_lines = _slice_lines(head_path, *right_range) if right_range else []
|
|
111
|
+
left_lines = _slice_lines(base_path, *left_range) if left_range else []
|
|
112
|
+
diff = difflib.unified_diff(
|
|
113
|
+
left_lines,
|
|
114
|
+
right_lines,
|
|
115
|
+
fromfile=f"base/{file_path}",
|
|
116
|
+
tofile=f"head/{file_path}",
|
|
117
|
+
lineterm="",
|
|
118
|
+
)
|
|
119
|
+
return "\n".join(diff)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _slice_lines(path: Path, start: int, end: int) -> list[str]:
|
|
123
|
+
if not path.exists():
|
|
124
|
+
return []
|
|
125
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
126
|
+
lines = text.splitlines()
|
|
127
|
+
# 1-indexed inclusive → Python slice.
|
|
128
|
+
return lines[max(0, start - 1) : end]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Anthropic prompt-caching settings applied to every fold-summary call.
|
|
132
|
+
# The cacheable prefix is the system prompt + the overview JSON + the
|
|
133
|
+
# file summary; folds within the same file (and across files within the
|
|
134
|
+
# PR) reuse it. Settings are no-ops on non-Anthropic backends.
|
|
135
|
+
_FOLD_CACHE_SETTINGS: dict[str, Any] = {
|
|
136
|
+
"anthropic_cache_instructions": True,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _format_fold_prompt(
|
|
141
|
+
*,
|
|
142
|
+
overview_json: str,
|
|
143
|
+
file_path: str,
|
|
144
|
+
file_summary: str,
|
|
145
|
+
context: FoldContext,
|
|
146
|
+
body: str,
|
|
147
|
+
right_range: tuple[int, int] | None,
|
|
148
|
+
left_range: tuple[int, int] | None,
|
|
149
|
+
qualified_name: str | None = None,
|
|
150
|
+
kind: str | None = None,
|
|
151
|
+
) -> list[UserContent]:
|
|
152
|
+
if context == "right":
|
|
153
|
+
rs, re_ = right_range or (0, 0)
|
|
154
|
+
region_label = f"post-image lines head/{file_path}:{rs}..{re_}"
|
|
155
|
+
elif context == "left":
|
|
156
|
+
ls, le = left_range or (0, 0)
|
|
157
|
+
region_label = f"pre-image (deleted) lines base/{file_path}:{ls}..{le}"
|
|
158
|
+
else:
|
|
159
|
+
rs, re_ = right_range or (0, 0)
|
|
160
|
+
ls, le = left_range or (0, 0)
|
|
161
|
+
region_label = f"both sides — head/{file_path}:{rs}..{re_} vs base/{file_path}:{ls}..{le}"
|
|
162
|
+
# When the region snapped to a definition, name it so the model
|
|
163
|
+
# describes that symbol's effect rather than re-deriving its identity
|
|
164
|
+
# from the body text. Indentation-fallback regions carry no symbol.
|
|
165
|
+
symbol_block = ""
|
|
166
|
+
if qualified_name:
|
|
167
|
+
kind_label = f"{kind} " if kind else ""
|
|
168
|
+
symbol_block = f"# Symbol\nThis fold is {kind_label}`{qualified_name}`.\n\n"
|
|
169
|
+
region_text = (
|
|
170
|
+
f"# File\npath: {file_path}\n\n"
|
|
171
|
+
f"{symbol_block}"
|
|
172
|
+
f"# Folded region — context: {context}; {region_label}\n"
|
|
173
|
+
f"{body}\n\n"
|
|
174
|
+
"Summarise the folded region."
|
|
175
|
+
)
|
|
176
|
+
# CachePoint markers between sections so Anthropic caches the
|
|
177
|
+
# overview prefix (cross-file) and the overview+file_summary
|
|
178
|
+
# prefix (within-file) across fold-summary calls. Other providers
|
|
179
|
+
# silently filter the markers out.
|
|
180
|
+
return [
|
|
181
|
+
f"# PR overview\n{overview_json}",
|
|
182
|
+
CachePoint(),
|
|
183
|
+
f"# File summary\n{file_summary}",
|
|
184
|
+
CachePoint(),
|
|
185
|
+
region_text,
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def summarise_fold(
|
|
190
|
+
client: Client,
|
|
191
|
+
*,
|
|
192
|
+
run_dir: Path,
|
|
193
|
+
file_path: str,
|
|
194
|
+
file_summary: str,
|
|
195
|
+
overview_json: str,
|
|
196
|
+
context: FoldContext,
|
|
197
|
+
right_range: tuple[int, int] | None,
|
|
198
|
+
left_range: tuple[int, int] | None,
|
|
199
|
+
model: str,
|
|
200
|
+
qualified_name: str | None = None,
|
|
201
|
+
kind: str | None = None,
|
|
202
|
+
cache: CacheStore | None = None,
|
|
203
|
+
trace_dir: Path | None = None,
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Return a one-sentence summary for a fold region.
|
|
206
|
+
|
|
207
|
+
Cached by `(file path, ranges, context, symbol, body content hash)`.
|
|
208
|
+
Trace file (if `trace_dir` is given) lands at
|
|
209
|
+
`trace_dir/fold-<file>-<context><range>.json` so failures are
|
|
210
|
+
diagnosable alongside the per-hunk traces.
|
|
211
|
+
"""
|
|
212
|
+
body = extract_fold_body(
|
|
213
|
+
run_dir,
|
|
214
|
+
file_path,
|
|
215
|
+
context,
|
|
216
|
+
right_range,
|
|
217
|
+
left_range,
|
|
218
|
+
)
|
|
219
|
+
payload = await run_pass(
|
|
220
|
+
_FOLD,
|
|
221
|
+
client=client,
|
|
222
|
+
agent=make_fold_summary_agent(client.model),
|
|
223
|
+
user_content=_format_fold_prompt(
|
|
224
|
+
overview_json=overview_json,
|
|
225
|
+
file_path=file_path,
|
|
226
|
+
file_summary=file_summary,
|
|
227
|
+
context=context,
|
|
228
|
+
body=body,
|
|
229
|
+
right_range=right_range,
|
|
230
|
+
left_range=left_range,
|
|
231
|
+
qualified_name=qualified_name,
|
|
232
|
+
kind=kind,
|
|
233
|
+
),
|
|
234
|
+
system=FOLD_SYSTEM,
|
|
235
|
+
model=model,
|
|
236
|
+
cache_inputs=(
|
|
237
|
+
overview_json,
|
|
238
|
+
file_summary,
|
|
239
|
+
file_path,
|
|
240
|
+
context,
|
|
241
|
+
str(right_range or ""),
|
|
242
|
+
str(left_range or ""),
|
|
243
|
+
# Symbol identity seeds the prompt, so vary the cache key by it.
|
|
244
|
+
qualified_name or "",
|
|
245
|
+
kind or "",
|
|
246
|
+
# Include the body so a re-run after the file content changed
|
|
247
|
+
# is invalidated even when ranges happen to line up.
|
|
248
|
+
body,
|
|
249
|
+
),
|
|
250
|
+
model_settings=_FOLD_CACHE_SETTINGS,
|
|
251
|
+
cache=cache,
|
|
252
|
+
trace_path=_trace_path(
|
|
253
|
+
trace_dir,
|
|
254
|
+
file_path,
|
|
255
|
+
context,
|
|
256
|
+
right_range,
|
|
257
|
+
left_range,
|
|
258
|
+
),
|
|
259
|
+
cache_request={
|
|
260
|
+
"file": file_path,
|
|
261
|
+
"context": context,
|
|
262
|
+
"right_range": right_range,
|
|
263
|
+
"left_range": left_range,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
assert payload is not None # `_FOLD.swallow_errors` is false
|
|
267
|
+
return str(payload.get("summary", "")).strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class FoldSummaryNotReady(RuntimeError):
|
|
271
|
+
"""The run dir doesn't yet hold an `augmented.scr.json`.
|
|
272
|
+
|
|
273
|
+
Maps to HTTP 409 at the review-server boundary — the augmentation
|
|
274
|
+
pass is still in flight or was skipped entirely.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class FoldSummaryFileIndexError(LookupError):
|
|
279
|
+
"""`file_idx` from the request doesn't address a file in the diff.
|
|
280
|
+
|
|
281
|
+
Maps to HTTP 404 at the review-server boundary.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def apply_fold_summary_to_run(
|
|
286
|
+
client: Client,
|
|
287
|
+
*,
|
|
288
|
+
run_dir: Path,
|
|
289
|
+
file_idx: int,
|
|
290
|
+
context: FoldContext,
|
|
291
|
+
right_range: tuple[int, int] | None,
|
|
292
|
+
left_range: tuple[int, int] | None,
|
|
293
|
+
model: str,
|
|
294
|
+
qualified_name: str | None = None,
|
|
295
|
+
kind: str | None = None,
|
|
296
|
+
cache: CacheStore | None = None,
|
|
297
|
+
trace_dir: Path | None = None,
|
|
298
|
+
) -> dict[str, Any]:
|
|
299
|
+
"""End-to-end fold-summary: resolve, call LLM, persist, return payload.
|
|
300
|
+
|
|
301
|
+
Loads the sidecar, looks up `file_idx`, calls :func:`summarise_fold`
|
|
302
|
+
against the resolved file, writes the new `FoldDescription` back to
|
|
303
|
+
`augmented.scr.json` + `augmented.diff`, and returns the broadcast
|
|
304
|
+
payload (the dict the review server fans out as an SSE event and
|
|
305
|
+
sends back to the requesting tab).
|
|
306
|
+
|
|
307
|
+
Raises :class:`FoldSummaryNotReady` if the sidecar isn't on disk
|
|
308
|
+
and :class:`FoldSummaryFileIndexError` if `file_idx` is out of
|
|
309
|
+
range. Other exceptions (LLM failures, write errors) propagate.
|
|
310
|
+
"""
|
|
311
|
+
sidecar = run_dir / "augmented.scr.json"
|
|
312
|
+
if not sidecar.exists():
|
|
313
|
+
raise FoldSummaryNotReady("augmented.scr.json missing — augment not complete")
|
|
314
|
+
|
|
315
|
+
# Lazy: keeps the augment-side format machinery off the import
|
|
316
|
+
# path for callers that only want :func:`summarise_fold`.
|
|
317
|
+
from ..format.emit import emit_augmented_diff
|
|
318
|
+
from ..format.sidecar import dump_sidecar, load_sidecar
|
|
319
|
+
from .hunks import overview_to_prompt_json
|
|
320
|
+
|
|
321
|
+
diff = load_sidecar(sidecar)
|
|
322
|
+
if not (0 <= file_idx < len(diff.files)):
|
|
323
|
+
raise FoldSummaryFileIndexError(f"file_idx {file_idx} not in diff")
|
|
324
|
+
|
|
325
|
+
fp = diff.files[file_idx]
|
|
326
|
+
summary = await summarise_fold(
|
|
327
|
+
client,
|
|
328
|
+
run_dir=run_dir,
|
|
329
|
+
file_path=fp.path,
|
|
330
|
+
file_summary=(fp.ann.summary or "").strip(),
|
|
331
|
+
overview_json=overview_to_prompt_json(diff),
|
|
332
|
+
context=context,
|
|
333
|
+
right_range=right_range,
|
|
334
|
+
left_range=left_range,
|
|
335
|
+
model=model,
|
|
336
|
+
qualified_name=qualified_name,
|
|
337
|
+
kind=kind,
|
|
338
|
+
cache=cache,
|
|
339
|
+
trace_dir=trace_dir,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
rs, re_ = right_range or (0, 0)
|
|
343
|
+
ls, le = left_range or (0, 0)
|
|
344
|
+
|
|
345
|
+
# Persist iff there's a hunk to stash the description on; see the
|
|
346
|
+
# comment on _attach_fold_summary for why the file's first hunk is
|
|
347
|
+
# the chosen home.
|
|
348
|
+
if fp.hunks:
|
|
349
|
+
updated_diff = _attach_fold_summary(
|
|
350
|
+
diff,
|
|
351
|
+
file_idx=file_idx,
|
|
352
|
+
context=context,
|
|
353
|
+
right=(rs, re_),
|
|
354
|
+
left=(ls, le),
|
|
355
|
+
summary=summary,
|
|
356
|
+
)
|
|
357
|
+
dump_sidecar(updated_diff, sidecar)
|
|
358
|
+
(run_dir / "augmented.diff").write_text(
|
|
359
|
+
emit_augmented_diff(updated_diff),
|
|
360
|
+
encoding="utf-8",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
"file_idx": file_idx,
|
|
365
|
+
"context": context,
|
|
366
|
+
"right_start": rs,
|
|
367
|
+
"right_end": re_,
|
|
368
|
+
"left_start": ls,
|
|
369
|
+
"left_end": le,
|
|
370
|
+
"summary": summary,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _attach_fold_summary(
|
|
375
|
+
diff: AnnotatedDiff,
|
|
376
|
+
*,
|
|
377
|
+
file_idx: int,
|
|
378
|
+
context: FoldContext,
|
|
379
|
+
right: tuple[int, int],
|
|
380
|
+
left: tuple[int, int],
|
|
381
|
+
summary: str,
|
|
382
|
+
) -> AnnotatedDiff:
|
|
383
|
+
"""Return `diff` with the matching `FoldDescription` replaced or
|
|
384
|
+
appended on the addressed file's first hunk's annotations.
|
|
385
|
+
|
|
386
|
+
Fold descriptions live at the hunk level for legacy reasons; for
|
|
387
|
+
v2 (file-level) addressing they describe content addressed at the
|
|
388
|
+
*file* level, so we stash them on the file's first hunk (chosen as
|
|
389
|
+
a stable home) until the schema migrates `fold_descriptions` up to
|
|
390
|
+
`AnnotatedFile`.
|
|
391
|
+
"""
|
|
392
|
+
fp = diff.files[file_idx]
|
|
393
|
+
rs, re_ = right
|
|
394
|
+
ls, le = left
|
|
395
|
+
hunk = fp.hunks[0]
|
|
396
|
+
new_folds = [
|
|
397
|
+
fd
|
|
398
|
+
for fd in hunk.ann.fold_descriptions
|
|
399
|
+
if not (
|
|
400
|
+
fd.context == context
|
|
401
|
+
and fd.right_start == rs
|
|
402
|
+
and fd.right_end == re_
|
|
403
|
+
and fd.left_start == ls
|
|
404
|
+
and fd.left_end == le
|
|
405
|
+
)
|
|
406
|
+
]
|
|
407
|
+
new_folds.append(
|
|
408
|
+
FoldDescription(
|
|
409
|
+
context=context,
|
|
410
|
+
right_start=rs,
|
|
411
|
+
right_end=re_,
|
|
412
|
+
left_start=ls,
|
|
413
|
+
left_end=le,
|
|
414
|
+
summary=summary,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
updated_ann = hunk.ann.model_copy(update={"fold_descriptions": new_folds})
|
|
418
|
+
updated_hunk = hunk.model_copy(update={"ann": updated_ann})
|
|
419
|
+
updated_hunks = list(fp.hunks)
|
|
420
|
+
updated_hunks[0] = updated_hunk
|
|
421
|
+
updated_file = fp.model_copy(update={"hunks": updated_hunks})
|
|
422
|
+
updated_files = list(diff.files)
|
|
423
|
+
updated_files[file_idx] = updated_file
|
|
424
|
+
return diff.model_copy(update={"files": updated_files})
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _trace_path(
|
|
428
|
+
trace_dir: Path | None,
|
|
429
|
+
file_path: str,
|
|
430
|
+
context: FoldContext,
|
|
431
|
+
right_range: tuple[int, int] | None,
|
|
432
|
+
left_range: tuple[int, int] | None,
|
|
433
|
+
) -> Path | None:
|
|
434
|
+
if trace_dir is None:
|
|
435
|
+
return None
|
|
436
|
+
safe_file = file_path.replace("/", "_")
|
|
437
|
+
if context == "right":
|
|
438
|
+
rs, re_ = right_range or (0, 0)
|
|
439
|
+
tag = f"r{rs}_{re_}"
|
|
440
|
+
elif context == "left":
|
|
441
|
+
ls, le = left_range or (0, 0)
|
|
442
|
+
tag = f"l{ls}_{le}"
|
|
443
|
+
else:
|
|
444
|
+
rs, re_ = right_range or (0, 0)
|
|
445
|
+
ls, le = left_range or (0, 0)
|
|
446
|
+
tag = f"b_r{rs}_{re_}_l{ls}_{le}"
|
|
447
|
+
return trace_dir / f"fold-{safe_file}-{tag}.json"
|