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