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,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"