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,264 @@
1
+ """Per-hunk pass: intent + segments + smells + context + refs.
2
+
3
+ Fused into a single call per hunk for v1. The system prompt frames the
4
+ job as comprehension-first; smells are secondary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic_ai import CachePoint
15
+ from pydantic_ai.messages import UserContent
16
+
17
+ from ..augment.schemas import (
18
+ AnnotatedDiff,
19
+ AnnotatedFile,
20
+ AnnotatedHunk,
21
+ FoldDescription,
22
+ HunkAnnotations,
23
+ LineNote,
24
+ Overview,
25
+ ParsedHunk,
26
+ Ref,
27
+ Segment,
28
+ Smell,
29
+ )
30
+ from ..cache.store import CacheStore
31
+ from .agents import Client, make_hunk_agent
32
+ from .pass_ import PassMeta, run_pass
33
+ from .prompts import HUNK_SYSTEM
34
+ from .tools import TOOL_FUNCTIONS, RepoTools
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ _HUNK = PassMeta(
39
+ name="hunk",
40
+ submit_tool="submit_annotations",
41
+ tool_names=tuple(fn.__name__ for fn in TOOL_FUNCTIONS),
42
+ )
43
+
44
+
45
+ # Anthropic prompt-caching settings applied to every per-hunk call.
46
+ #
47
+ # The cacheable prefix on this pass is `[system prompt] + [tool defs] +
48
+ # [overview] + [file summary]`. The first two stay byte-identical for
49
+ # every hunk in the run, so caching them buys cross-hunk reuse on a
50
+ # multi-hunk PR. The CachePoint markers in `format_hunk_prompt` then
51
+ # split the user prompt so within-file (overview + summary cached) and
52
+ # within-PR (overview cached) prefixes are reused too.
53
+ #
54
+ # AnthropicModelSettings keys are silently ignored by non-Anthropic
55
+ # backends (TypedDict total=False), so this is safe to apply
56
+ # unconditionally — Google + the CLI drivers see them as no-ops.
57
+ _HUNK_CACHE_SETTINGS: dict[str, Any] = {
58
+ "anthropic_cache_instructions": True, # system prompt block
59
+ "anthropic_cache_tool_definitions": True, # tools/<RepoTools>
60
+ }
61
+
62
+
63
+ def format_hunk_prompt(
64
+ fp: AnnotatedFile,
65
+ hunk: AnnotatedHunk,
66
+ overview_json: str,
67
+ file_summary: str,
68
+ ) -> list[UserContent]:
69
+ """Assemble the user-prompt blocks for one hunk call.
70
+
71
+ Returns a `UserContent` list with `CachePoint` markers between the
72
+ cacheable prefix sections (overview, file summary) and the
73
+ per-hunk text. pydantic-ai's Anthropic adapter translates each
74
+ `CachePoint` into a `cache_control: ephemeral` annotation on the
75
+ preceding text block; non-supporting providers filter the markers
76
+ out and concatenate the text blocks. Fold-region summaries are not
77
+ produced here — the review server fires a focused call on first
78
+ fold-close; see :mod:`semantic_code_review.augment.fold_summary`.
79
+ """
80
+ hunk_text = (
81
+ f"# File\npath: {fp.path}\nlang: {fp.ann.lang or ''}\n\n# Hunk\n{hunk.parsed.header}\n{hunk.parsed.body}"
82
+ )
83
+ return [
84
+ f"# PR overview\n{overview_json}",
85
+ CachePoint(),
86
+ f"# File summary\n{file_summary}",
87
+ CachePoint(),
88
+ hunk_text,
89
+ ]
90
+
91
+
92
+ def _hunk_trace_path(
93
+ trace_dir: Path | None,
94
+ fp: AnnotatedFile,
95
+ hunk: AnnotatedHunk,
96
+ ) -> Path | None:
97
+ if trace_dir is None:
98
+ return None
99
+ safe_file = fp.path.replace("/", "_")
100
+ safe_hunk = (
101
+ hunk.parsed.header.replace(" ", "_").replace("@", "").replace(",", "_").replace("+", "p").replace("-", "m")
102
+ )
103
+ return trace_dir / f"hunk-{safe_file}-{safe_hunk[:40]}.json"
104
+
105
+
106
+ async def run_hunk_pass(
107
+ client: Client,
108
+ *,
109
+ fp: AnnotatedFile,
110
+ hunk: AnnotatedHunk,
111
+ overview_json: str,
112
+ file_summary: str,
113
+ repo_tools: RepoTools,
114
+ model: str,
115
+ cache: CacheStore | None = None,
116
+ trace_dir: Path | None = None,
117
+ ) -> dict[str, Any]:
118
+ payload = await run_pass(
119
+ _HUNK,
120
+ client=client,
121
+ agent=make_hunk_agent(client.model),
122
+ user_content=format_hunk_prompt(fp, hunk, overview_json, file_summary),
123
+ system=HUNK_SYSTEM,
124
+ model=model,
125
+ cache_inputs=(
126
+ overview_json,
127
+ file_summary,
128
+ fp.path,
129
+ hunk.parsed.header,
130
+ hunk.parsed.body,
131
+ ),
132
+ deps=repo_tools,
133
+ model_settings=_HUNK_CACHE_SETTINGS,
134
+ cache=cache,
135
+ trace_path=_hunk_trace_path(trace_dir, fp, hunk),
136
+ cache_request={
137
+ "file": fp.path,
138
+ "header": hunk.parsed.header,
139
+ "body_len": len(hunk.parsed.body),
140
+ },
141
+ )
142
+ assert payload is not None # `_HUNK.swallow_errors` is false
143
+ return payload
144
+
145
+
146
+ def build_hunk_annotations(parsed: ParsedHunk, submit_args: dict[str, Any]) -> HunkAnnotations:
147
+ """Validate a submit_annotations payload against `parsed` and return
148
+ a `HunkAnnotations` record.
149
+
150
+ Drops segments/fold_descriptions outside the hunk's post-image range
151
+ or overlapping a previously-kept segment — the LLM occasionally emits
152
+ pre-image line numbers or off-by-a-few ranges.
153
+ """
154
+ hunk_end = parsed.new_start + parsed.new_count - 1
155
+
156
+ segments: list[Segment] = []
157
+ last_end = parsed.new_start - 1
158
+ for seg in submit_args.get("segments") or []:
159
+ try:
160
+ start = int(seg["new_start"])
161
+ count = int(seg["new_count"])
162
+ except (KeyError, TypeError, ValueError):
163
+ log.warning("hunk %s: malformed segment %r — dropped", parsed.header, seg)
164
+ continue
165
+ end = start + count - 1
166
+ if count <= 0 or start < parsed.new_start or end > hunk_end:
167
+ log.warning(
168
+ "hunk %s: segment +%d..+%d outside range +%d..+%d — dropped",
169
+ parsed.header,
170
+ start,
171
+ end,
172
+ parsed.new_start,
173
+ hunk_end,
174
+ )
175
+ continue
176
+ if start <= last_end:
177
+ log.warning(
178
+ "hunk %s: segment +%d..+%d overlaps previous (ends +%d) — dropped",
179
+ parsed.header,
180
+ start,
181
+ end,
182
+ last_end,
183
+ )
184
+ continue
185
+ segments.append(
186
+ Segment(
187
+ new_start=start,
188
+ new_count=count,
189
+ intent=seg.get("intent", "") or "",
190
+ smells=[_smell(s) for s in seg.get("smells") or []],
191
+ context=seg.get("context", "") or "",
192
+ refs=[Ref(**_ref(r)) for r in seg.get("refs") or []],
193
+ )
194
+ )
195
+ last_end = end
196
+
197
+ fold_descriptions: list[FoldDescription] = []
198
+ for fd in submit_args.get("fold_descriptions") or []:
199
+ try:
200
+ start = int(fd["new_start"])
201
+ count = int(fd["new_count"])
202
+ except (KeyError, TypeError, ValueError):
203
+ log.warning("hunk %s: malformed fold_description %r — dropped", parsed.header, fd)
204
+ continue
205
+ end = start + count - 1
206
+ if count <= 0 or start < parsed.new_start or end > hunk_end:
207
+ log.warning(
208
+ "hunk %s: fold +%d..+%d outside range — dropped",
209
+ parsed.header,
210
+ start,
211
+ end,
212
+ )
213
+ continue
214
+ summary = (fd.get("summary") or "").strip()
215
+ if not summary:
216
+ continue
217
+ fold_descriptions.append(FoldDescription(right_start=start, right_end=end, summary=summary))
218
+
219
+ line_notes = [
220
+ LineNote(**ln) for ln in submit_args.get("line_notes") or [] if _line_in_hunk(int(ln["line"]), parsed)
221
+ ]
222
+
223
+ return HunkAnnotations(
224
+ intent=submit_args.get("intent", "") or "",
225
+ context=submit_args.get("context", "") or "",
226
+ confidence=submit_args.get("confidence"),
227
+ smells=[_smell(s) for s in submit_args.get("smells") or []],
228
+ refs=[Ref(**_ref(r)) for r in submit_args.get("refs") or []],
229
+ line_notes=line_notes,
230
+ segments=segments,
231
+ fold_descriptions=fold_descriptions,
232
+ )
233
+
234
+
235
+ def apply_hunk_annotations(hunk: AnnotatedHunk, submit_args: dict[str, Any]) -> AnnotatedHunk:
236
+ """Return a new AnnotatedHunk with `ann` set from `submit_args`."""
237
+ return hunk.model_copy(update={"ann": build_hunk_annotations(hunk.parsed, submit_args)})
238
+
239
+
240
+ def _line_in_hunk(line: int, parsed: ParsedHunk) -> bool:
241
+ return parsed.new_start <= line <= parsed.new_start + parsed.new_count - 1
242
+
243
+
244
+ def _smell(d: dict[str, Any]) -> Smell:
245
+ return Smell(tag=d.get("tag", ""), note=d.get("note", "") or "")
246
+
247
+
248
+ def _ref(d: dict[str, Any]) -> dict[str, Any]:
249
+ return {"path": d["path"], "line": int(d["line"]), "reason": d.get("reason", "") or ""}
250
+
251
+
252
+ def overview_to_prompt_json(diff: AnnotatedDiff) -> str:
253
+ """Serialize the overview into a compact JSON string for the hunk prompt."""
254
+ if not isinstance(diff.overview, Overview):
255
+ return "{}"
256
+ payload = {
257
+ "summary": diff.overview.summary,
258
+ "symbols_added": [s.model_dump() for s in diff.overview.symbols_added],
259
+ "symbols_modified": [s.model_dump() for s in diff.overview.symbols_modified],
260
+ "symbols_removed": [s.model_dump() for s in diff.overview.symbols_removed],
261
+ "callgraph_edges": [e.model_dump(by_alias=True) for e in diff.overview.callgraph_edges],
262
+ "themes": list(diff.overview.themes),
263
+ }
264
+ return json.dumps(payload, ensure_ascii=False)
@@ -0,0 +1,156 @@
1
+ """Minimal stdio MCP server exposing `RepoTools` to `claude -p`.
2
+
3
+ Used by `ClaudeCLIClient` when a RepoTools instance is available: we
4
+ inject this server via `claude -p --mcp-config ... --strict-mcp-config`
5
+ so the agentic loop can still `read_file`, `grep`, `list_dir`,
6
+ `read_file_at`, and `git_log` against the run's worktrees — matching
7
+ the behaviour of the API backend's in-process tool loop.
8
+
9
+ Protocol: JSON-RPC 2.0 over newline-delimited JSON on stdio, per the
10
+ MCP stdio transport spec. Only a subset is implemented:
11
+
12
+ - initialize (request)
13
+ - notifications/initialized (notification, no response)
14
+ - tools/list (request)
15
+ - tools/call (request)
16
+
17
+ Anything else returns a JSON-RPC method-not-found error. No resources,
18
+ prompts, or sampling — we don't need them for read-only repo access.
19
+
20
+ Run directly::
21
+
22
+ python -m semantic_code_review.augment.mcp_server \
23
+ --head-worktree <path> --repo-git <path> \
24
+ --base-sha <sha> --head-sha <sha>
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import logging
32
+ import sys
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from .tools import RepoTools, mcp_dispatch, mcp_tool_schemas
37
+
38
+ log = logging.getLogger("scr.mcp_server")
39
+
40
+
41
+ PROTOCOL_VERSION = "2025-06-18"
42
+ SERVER_NAME = "scr"
43
+ SERVER_VERSION = "0.1.0"
44
+
45
+
46
+ def _make_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
47
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
48
+
49
+
50
+ def _make_result(req_id: Any, result: Any) -> dict[str, Any]:
51
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
52
+
53
+
54
+ def _handle(req: dict[str, Any], repo_tools: RepoTools) -> dict[str, Any] | None:
55
+ """Return a response dict, or None for notifications."""
56
+ method = req.get("method", "")
57
+ req_id = req.get("id")
58
+ params = req.get("params") or {}
59
+ is_notification = "id" not in req
60
+
61
+ if method == "initialize":
62
+ return _make_result(
63
+ req_id,
64
+ {
65
+ "protocolVersion": PROTOCOL_VERSION,
66
+ "capabilities": {"tools": {"listChanged": False}},
67
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
68
+ },
69
+ )
70
+
71
+ if method == "notifications/initialized":
72
+ # Notification: no response.
73
+ return None
74
+
75
+ if method == "tools/list":
76
+ return _make_result(req_id, {"tools": mcp_tool_schemas()})
77
+
78
+ if method == "tools/call":
79
+ name = params.get("name", "")
80
+ args = params.get("arguments") or {}
81
+ try:
82
+ output = mcp_dispatch(repo_tools, name, args)
83
+ return _make_result(
84
+ req_id,
85
+ {
86
+ "content": [{"type": "text", "text": output}],
87
+ "isError": output.startswith("error:"),
88
+ },
89
+ )
90
+ except Exception as e: # noqa: BLE001
91
+ return _make_result(
92
+ req_id,
93
+ {
94
+ "content": [{"type": "text", "text": f"error: {e}"}],
95
+ "isError": True,
96
+ },
97
+ )
98
+
99
+ if is_notification:
100
+ # Drop unknown notifications silently.
101
+ return None
102
+
103
+ return _make_error(req_id, -32601, f"method not found: {method}")
104
+
105
+
106
+ def serve(repo_tools: RepoTools, stdin: Any = None, stdout: Any = None) -> None:
107
+ """Loop over newline-delimited JSON-RPC on stdio until EOF."""
108
+ stdin = stdin or sys.stdin
109
+ stdout = stdout or sys.stdout
110
+ for raw in stdin:
111
+ line = raw.strip()
112
+ if not line:
113
+ continue
114
+ try:
115
+ req = json.loads(line)
116
+ except json.JSONDecodeError as e:
117
+ log.warning("dropping malformed JSON-RPC line: %s", e)
118
+ continue
119
+ try:
120
+ response = _handle(req, repo_tools)
121
+ except Exception as e:
122
+ log.exception("handler crashed on request %s", req.get("method"))
123
+ response = _make_error(req.get("id"), -32603, f"internal error: {e}")
124
+ if response is not None:
125
+ stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
126
+ stdout.flush()
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> int:
130
+ parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
131
+ parser.add_argument("--head-worktree", required=True, type=Path)
132
+ parser.add_argument("--repo-git", required=True, type=Path)
133
+ parser.add_argument("--base-sha", default="")
134
+ parser.add_argument("--head-sha", default="")
135
+ parser.add_argument("--log-file", type=Path, default=None)
136
+ args = parser.parse_args(argv)
137
+
138
+ if args.log_file is not None:
139
+ logging.basicConfig(
140
+ filename=str(args.log_file),
141
+ level=logging.INFO,
142
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
143
+ )
144
+
145
+ repo_tools = RepoTools(
146
+ head_worktree=args.head_worktree,
147
+ repo_git=args.repo_git,
148
+ base_sha=args.base_sha,
149
+ head_sha=args.head_sha,
150
+ )
151
+ serve(repo_tools)
152
+ return 0
153
+
154
+
155
+ if __name__ == "__main__":
156
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ """Overview pass: one call per PR producing the PR-level summary.
2
+
3
+ Input: PR metadata + diffstat + per-file hunk headers (bodies omitted
4
+ to save tokens). Output: the `Overview` object plus per-file summary
5
+ text and optional `lang` override that populate `FileAnnotations` fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..augment.schemas import (
15
+ AnnotatedDiff,
16
+ AnnotatedFile,
17
+ FileSymbols,
18
+ Overview,
19
+ OverviewEdge,
20
+ OverviewGroup,
21
+ OverviewGroupMember,
22
+ OverviewSymbol,
23
+ )
24
+ from ..cache.store import CacheStore
25
+ from ..structural import SymbolDelta
26
+ from .agents import Client, make_overview_agent
27
+ from .pass_ import PassMeta, run_pass
28
+ from .prompts import OVERVIEW_SYSTEM
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ _OVERVIEW = PassMeta(name="overview", submit_tool="submit_overview")
34
+
35
+
36
+ def format_overview_prompt(
37
+ diff: AnnotatedDiff,
38
+ meta: dict[str, Any],
39
+ delta: SymbolDelta | None = None,
40
+ ) -> str:
41
+ """Produce the user-message text for the overview call.
42
+
43
+ `delta`, when present and non-empty, seeds the prompt with the
44
+ deterministic tree-sitter symbol delta (ADR 0001) so the model
45
+ reports `symbols_*` from ground truth rather than guessing from hunk
46
+ headers. An absent or empty delta (no supported-language change)
47
+ appends nothing — the prompt is byte-identical to the pre-seed form.
48
+ """
49
+ parts: list[str] = []
50
+ title = meta.get("title", "")
51
+ body = (meta.get("body") or "").strip()
52
+ parts.append(f"# PR\ntitle: {title}\n")
53
+ if body:
54
+ # Trim body — overview doesn't need the full novel.
55
+ if len(body) > 4000:
56
+ body = body[:4000] + "\n... [PR body truncated for brevity] ..."
57
+ parts.append(f"body:\n{body}\n")
58
+
59
+ parts.append("# Diffstat")
60
+ for f in diff.files:
61
+ adds = sum(sum(1 for ln in h.parsed.body.splitlines() if ln.startswith("+")) for h in f.hunks)
62
+ dels = sum(sum(1 for ln in h.parsed.body.splitlines() if ln.startswith("-")) for h in f.hunks)
63
+ parts.append(f" {f.path} +{adds} -{dels} ({len(f.hunks)} hunks)")
64
+
65
+ # Each hunk header is prefixed with its 0-based `hunk_index` within
66
+ # the file, so the model can cite `{path, hunk_index}` from the
67
+ # `groups` output unambiguously.
68
+ parts.append("\n# Hunk headers")
69
+ for f in diff.files:
70
+ parts.append(f"{f.path}")
71
+ for i, h in enumerate(f.hunks):
72
+ parts.append(f" [{i}] {h.parsed.header}")
73
+
74
+ seed = _format_symbol_seed(delta)
75
+ if seed:
76
+ parts.append(seed)
77
+
78
+ return "\n".join(parts) + "\n"
79
+
80
+
81
+ def _format_symbol_seed(delta: SymbolDelta | None) -> str:
82
+ """Render the deterministic symbol delta as a prompt section, or `""`.
83
+
84
+ Returns the empty string when there's nothing to seed (no delta, or
85
+ every bucket empty) so callers can keep the prompt byte-identical to
86
+ the unseeded form for all-unsupported-language diffs.
87
+ """
88
+ if delta is None:
89
+ return ""
90
+ buckets = (("added", delta.added), ("modified", delta.modified), ("removed", delta.removed))
91
+ if not any(items for _, items in buckets):
92
+ return ""
93
+ lines = [
94
+ "\n# Symbols changed (deterministic — tree-sitter, not your inference)",
95
+ "These are the exact definitions that changed between base and head, by "
96
+ "name and kind. Populate `symbols_added` / `symbols_modified` / "
97
+ "`symbols_removed` from THIS list verbatim — one entry per line below, "
98
+ "using its `qualified_name` as the symbol `name`. Do not add, drop, or "
99
+ "rename entries; languages absent here are simply not yet parseable.",
100
+ ]
101
+ for label, items in buckets:
102
+ lines.append(f"{label}:")
103
+ if not items:
104
+ lines.append(" (none)")
105
+ continue
106
+ for c in items:
107
+ lines.append(f" {c.kind} {c.qualified_name} ({c.path})")
108
+ return "\n".join(lines)
109
+
110
+
111
+ async def run_overview_pass(
112
+ client: Client,
113
+ *,
114
+ diff: AnnotatedDiff,
115
+ meta: dict[str, Any],
116
+ model: str,
117
+ delta: SymbolDelta | None = None,
118
+ cache: CacheStore | None = None,
119
+ trace_dir: Path | None = None,
120
+ ) -> dict[str, Any]:
121
+ """Run the overview call. Returns the raw submit_args from the model.
122
+
123
+ `delta`, when present, seeds the prompt with the deterministic
124
+ structural symbol delta (ADR 0001 Slice 3). It feeds the prompt text,
125
+ so the cache key (which hashes `user_text`) tracks it automatically.
126
+ """
127
+ user_text = format_overview_prompt(diff, meta, delta)
128
+ payload = await run_pass(
129
+ _OVERVIEW,
130
+ client=client,
131
+ agent=make_overview_agent(client.model),
132
+ user_content=user_text,
133
+ system=OVERVIEW_SYSTEM,
134
+ model=model,
135
+ cache_inputs=(user_text,),
136
+ cache=cache,
137
+ trace_path=(trace_dir / "overview.json") if trace_dir is not None else None,
138
+ cache_request={"system": OVERVIEW_SYSTEM, "user": user_text},
139
+ )
140
+ # `_OVERVIEW.swallow_errors` is false, so `payload` is never None here.
141
+ assert payload is not None
142
+ return payload
143
+
144
+
145
+ def apply_overview_to_diff(diff: AnnotatedDiff, submit_args: dict[str, Any]) -> AnnotatedDiff:
146
+ """Fold a submit_overview payload into an AnnotatedDiff. Returns a new
147
+ AnnotatedDiff; `diff` is not mutated.
148
+
149
+ Per-file fields named in the submission overwrite existing
150
+ `FileAnnotations.summary`/`lang`/`symbols`; files not named are
151
+ untouched (preserving e.g. the `GENERATED` role pre-set by the
152
+ pipeline for skipped files).
153
+ """
154
+ overview = Overview(
155
+ summary=submit_args.get("summary", ""),
156
+ symbols_added=[OverviewSymbol(**s) for s in submit_args.get("symbols_added", [])],
157
+ symbols_modified=[OverviewSymbol(**s) for s in submit_args.get("symbols_modified", [])],
158
+ symbols_removed=[OverviewSymbol(**s) for s in submit_args.get("symbols_removed", [])],
159
+ callgraph_edges=[OverviewEdge.model_validate(e) for e in submit_args.get("callgraph_edges", [])],
160
+ themes=list(submit_args.get("themes", [])),
161
+ groups=_resolve_groups(diff, submit_args.get("groups") or []),
162
+ )
163
+ by_path = {f["path"]: f for f in submit_args.get("files", [])}
164
+ new_files: list[AnnotatedFile] = []
165
+ for fp in diff.files:
166
+ entry = by_path.get(fp.path)
167
+ if entry is None:
168
+ new_files.append(fp)
169
+ continue
170
+ sym = entry.get("symbols")
171
+ ann = fp.ann.model_copy(
172
+ update={
173
+ "summary": entry.get("summary", ""),
174
+ **({"lang": entry["lang"]} if entry.get("lang") else {}),
175
+ **(
176
+ {
177
+ "symbols": FileSymbols(
178
+ added=list(sym.get("added", [])),
179
+ modified=list(sym.get("modified", [])),
180
+ removed=list(sym.get("removed", [])),
181
+ )
182
+ }
183
+ if isinstance(sym, dict)
184
+ else {}
185
+ ),
186
+ }
187
+ )
188
+ new_files.append(fp.model_copy(update={"ann": ann}))
189
+ return diff.model_copy(update={"overview": overview, "files": new_files})
190
+
191
+
192
+ def _resolve_groups(diff: AnnotatedDiff, raw_groups: list[dict[str, Any]]) -> list[OverviewGroup]:
193
+ """Build OverviewGroup instances from raw submit_overview payload.
194
+
195
+ Members whose (path, hunk_index) don't resolve to a real hunk in
196
+ the diff are dropped with a warning. A group whose members all get
197
+ dropped is itself dropped.
198
+ """
199
+ hunks_per_path: dict[str, int] = {fp.path: len(fp.hunks) for fp in diff.files}
200
+
201
+ out: list[OverviewGroup] = []
202
+ for raw in raw_groups:
203
+ title = (raw.get("title") or "").strip()
204
+ if not title:
205
+ continue
206
+ rationale = (raw.get("rationale") or "").strip()
207
+ members: list[OverviewGroupMember] = []
208
+ for m in raw.get("members") or []:
209
+ try:
210
+ path = str(m["path"])
211
+ idx = int(m["hunk_index"])
212
+ except (KeyError, TypeError, ValueError):
213
+ log.warning("group %r: malformed member %r — dropped", title, m)
214
+ continue
215
+ n = hunks_per_path.get(path)
216
+ if n is None:
217
+ log.warning("group %r: path %r not in diff — dropped", title, path)
218
+ continue
219
+ if idx < 0 or idx >= n:
220
+ log.warning(
221
+ "group %r: hunk_index %d out of range for %s (n=%d) — dropped",
222
+ title,
223
+ idx,
224
+ path,
225
+ n,
226
+ )
227
+ continue
228
+ members.append(OverviewGroupMember(path=path, hunk_index=idx))
229
+ if not members:
230
+ log.warning("group %r: no valid members — dropped", title)
231
+ continue
232
+ out.append(OverviewGroup(title=title, rationale=rationale, members=members))
233
+ return out