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,475 @@
1
+ """Repo tools the LLM can call during a per-hunk pass.
2
+
3
+ Every tool is read-only, operates on the fetched worktrees, and returns
4
+ text. Large results are truncated and flagged so the model can narrow
5
+ its query.
6
+
7
+ `RepoTools` is the single source of truth for the tool surface. Methods
8
+ decorated with `@_tool` are exported to two consumers:
9
+
10
+ * pydantic-ai SDK Agents — via `TOOL_FUNCTIONS`, a list of `RunContext`-
11
+ wrapping callables produced from the decorated methods.
12
+ * The MCP stdio server (`mcp_server.py`) — via `mcp_tool_schemas()` for
13
+ the `tools/list` payload and `mcp_dispatch()` for `tools/call`.
14
+
15
+ Both surfaces are derived by introspection: rename a `RepoTools` method
16
+ and both update with no other edits.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import inspect
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, cast
29
+
30
+ from pydantic_ai import RunContext
31
+ from pydantic_ai.tools import Tool
32
+
33
+ from .. import git_ops, structural
34
+
35
+ TOOL_RESULT_CAP_BYTES = 20 * 1024
36
+
37
+
38
+ # Resolved once at import; tests that want to force a path can monkeypatch.
39
+ _HAS_RIPGREP = shutil.which("rg") is not None
40
+
41
+
42
+ _TOOL_EXPORT_ATTR = "_repo_tool_export"
43
+
44
+
45
+ def _tool(method: Callable) -> Callable:
46
+ """Mark a `RepoTools` method as part of the LLM-facing tool surface."""
47
+ setattr(method, _TOOL_EXPORT_ATTR, True)
48
+ return method
49
+
50
+
51
+ @dataclass
52
+ class RepoTools:
53
+ head_worktree: Path
54
+ repo_git: Path
55
+ base_sha: str
56
+ head_sha: str
57
+ # Optional augmented diff (an `AnnotatedDiff`), bound only by the
58
+ # review console so its `hunk(id)` accessor can resolve a viewer
59
+ # hunk id to that hunk's diff text. Kept loosely typed (`Any`) so
60
+ # the augment-side schemas stay off this module's import path;
61
+ # left None on every augment/MCP path, where no sidecar exists yet.
62
+ diff: Any = None
63
+
64
+ # --- file reads -------------------------------------------------------
65
+
66
+ @_tool
67
+ def read_file(self, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
68
+ """Read a file from the head worktree. Returns up to 20 KB of text.
69
+
70
+ Args:
71
+ path: Path relative to repo root.
72
+ start_line: 1-indexed start line (optional).
73
+ end_line: 1-indexed end line inclusive (optional).
74
+ """
75
+ full = (self.head_worktree / path).resolve()
76
+ if not _is_inside(full, self.head_worktree):
77
+ return f"error: path outside worktree: {path}"
78
+ if not full.exists():
79
+ return f"error: not found: {path}"
80
+ try:
81
+ text = full.read_text(encoding="utf-8", errors="replace")
82
+ except OSError as e:
83
+ return f"error: could not read {path}: {e}"
84
+ return _slice_and_cap(text, start_line, end_line)
85
+
86
+ @_tool
87
+ def read_file_at(self, sha: str, path: str, start_line: int | None = None, end_line: int | None = None) -> str:
88
+ """Read a file at a specific commit SHA (e.g. the PR base).
89
+
90
+ Use for pre-change content.
91
+
92
+ Args:
93
+ sha: Commit SHA.
94
+ path: Path relative to repo root.
95
+ start_line: 1-indexed start line (optional).
96
+ end_line: 1-indexed end line inclusive (optional).
97
+ """
98
+ rc, stdout, stderr = git_ops.git_capture(
99
+ self.repo_git,
100
+ "show",
101
+ f"{sha}:{path}",
102
+ )
103
+ if rc != 0:
104
+ return f"error: git show {sha}:{path} failed: {stderr.strip()}"
105
+ return _slice_and_cap(stdout, start_line, end_line)
106
+
107
+ # --- structure --------------------------------------------------------
108
+
109
+ @_tool
110
+ def outline(self, path: str, sha: str | None = None) -> str:
111
+ """Structural symbol outline of a file, as a JSON array.
112
+
113
+ Deterministic tree-sitter parse — no LLM, no hallucination. Each
114
+ entry is a definition (class / function / constant) with its
115
+ `name`, `qualified_name`, 1-indexed line `range`, declared
116
+ `signature` (or null), and nested `children` (class ▸ method).
117
+ Unsupported language or parse failure ⇒ `[]`.
118
+
119
+ Args:
120
+ path: Path relative to repo root.
121
+ sha: Commit SHA to read the file at (defaults to head worktree).
122
+ """
123
+ lang = structural.language_for_path(path)
124
+ if lang is None:
125
+ return "[]"
126
+ source = self._read_source(path, sha)
127
+ if source is None:
128
+ return "[]"
129
+ symbols = structural.outline_symbols(source, lang)
130
+ return _cap(structural.symbols_to_json(symbols))
131
+
132
+ def _read_source(self, path: str, sha: str | None) -> str | None:
133
+ """Raw file text from the head worktree (``sha is None``) or at a
134
+ revision via ``git show``. ``None`` if it can't be read.
135
+ """
136
+ if sha is None:
137
+ full = (self.head_worktree / path).resolve()
138
+ if not _is_inside(full, self.head_worktree) or not full.is_file():
139
+ return None
140
+ try:
141
+ return full.read_text(encoding="utf-8", errors="replace")
142
+ except OSError:
143
+ return None
144
+ rc, stdout, _stderr = git_ops.git_capture(self.repo_git, "show", f"{sha}:{path}")
145
+ return stdout if rc == 0 else None
146
+
147
+ @_tool
148
+ def symbol_at(self, path: str, line: int, sha: str | None = None) -> str:
149
+ """Innermost symbol enclosing a line, as a JSON object (or `null`).
150
+
151
+ Deterministic tree-sitter parse — no LLM. Returns the most
152
+ specific definition (the method, not its class) whose 1-indexed
153
+ line `range` covers `line`, with its `name`, `qualified_name`,
154
+ `signature`, and nested `children`. `null` if no symbol covers
155
+ the line, or the language is unsupported / file unreadable.
156
+
157
+ Args:
158
+ path: Path relative to repo root.
159
+ line: 1-indexed line number.
160
+ sha: Commit SHA to read the file at (defaults to head worktree).
161
+ """
162
+ lang = structural.language_for_path(path)
163
+ if lang is None:
164
+ return "null"
165
+ source = self._read_source(path, sha)
166
+ if source is None:
167
+ return "null"
168
+ symbols = structural.outline_symbols(source, lang)
169
+ return structural.symbol_to_json(structural.enclosing_symbol(symbols, line))
170
+
171
+ def compute_symbol_delta(self) -> structural.SymbolDelta:
172
+ """Deterministic base→head structural delta over the whole diff.
173
+
174
+ Compares the base commit against the head worktree for every
175
+ changed file in a supported language and merges the per-file
176
+ `qualified_name` set-diffs into one diff-wide `SymbolDelta`.
177
+ Changed files in unsupported languages are silently absent.
178
+
179
+ Underlies both the `changed_symbols` tool (JSON for the LLM) and
180
+ the overview seed (the `SymbolDelta` object, consumed in-process).
181
+ Raises `git_ops.GitError` if the diff can't be enumerated.
182
+ """
183
+ paths = git_ops.diff_name_only(self.repo_git, self.base_sha, self.head_sha)
184
+ deltas = []
185
+ for path in paths:
186
+ lang = structural.language_for_path(path)
187
+ if lang is None:
188
+ continue
189
+ base_src = self._read_source(path, self.base_sha)
190
+ head_src = self._read_source(path, None)
191
+ base_syms = structural.outline_symbols(base_src, lang) if base_src is not None else []
192
+ head_syms = structural.outline_symbols(head_src, lang) if head_src is not None else []
193
+ deltas.append(structural.diff_file(path, base_syms, head_syms))
194
+ return structural.merge(deltas)
195
+
196
+ @_tool
197
+ def changed_symbols(self) -> str:
198
+ """Deterministic structural delta of the whole diff, as JSON.
199
+
200
+ Compares the base commit against the head worktree for every
201
+ changed file in a supported language, returning
202
+ `{added, removed, modified}` lists of symbols by `qualified_name`
203
+ set-diff — no LLM, no hallucination. `modified` means the same
204
+ qualified name on both sides with a differing line range; a
205
+ same-span body edit is not flagged. Each entry carries its
206
+ `path`, `kind`, `name`, `qualified_name`, declared `signature`,
207
+ and the line `range` on its live side (head for added/modified,
208
+ base for removed). Changed files in unsupported languages are
209
+ silently absent.
210
+ """
211
+ try:
212
+ delta = self.compute_symbol_delta()
213
+ except git_ops.GitError as e:
214
+ return f"error: {e}"
215
+ return _cap(delta.model_dump_json())
216
+
217
+ # --- search -----------------------------------------------------------
218
+
219
+ @_tool
220
+ def grep(self, pattern: str, path_glob: str | None = None, max_hits: int = 50) -> str:
221
+ """Search the head worktree with ripgrep.
222
+
223
+ Returns path:line:text matches, capped at 50 by default. Falls back
224
+ to `git grep` when ripgrep is unavailable. Output is ``path:line:text``
225
+ with worktree prefix stripped.
226
+
227
+ Args:
228
+ pattern: Pattern to search for.
229
+ path_glob: Restrict to matching files (e.g. 'src/**/*.py').
230
+ max_hits: Maximum matches to return.
231
+ """
232
+ if _HAS_RIPGREP:
233
+ return self._grep_rg(pattern, path_glob, max_hits)
234
+ return self._grep_git(pattern, path_glob, max_hits)
235
+
236
+ def _grep_rg(self, pattern: str, path_glob: str | None, max_hits: int) -> str:
237
+ args = ["rg", "--no-heading", "-n", "--max-count", str(max_hits), "-e", pattern]
238
+ if path_glob:
239
+ args += ["--glob", path_glob]
240
+ args.append(str(self.head_worktree))
241
+ result = subprocess.run(args, capture_output=True, text=True, check=False)
242
+ if result.returncode not in (0, 1): # 1 = no matches
243
+ return f"error: rg failed: {result.stderr.strip()}"
244
+ prefix = str(self.head_worktree) + os.sep
245
+ out = "\n".join(line.removeprefix(prefix) for line in result.stdout.splitlines())
246
+ return _cap(out)
247
+
248
+ def _grep_git(self, pattern: str, path_glob: str | None, max_hits: int) -> str:
249
+ """Fallback search via ``git grep`` — always available since git is a
250
+ hard requirement. Respects .gitignore; only searches tracked files.
251
+ """
252
+ try:
253
+ return _cap(git_ops.grep(self.head_worktree, pattern, path_glob, max_hits))
254
+ except git_ops.GitError as e:
255
+ return f"error: {e}"
256
+
257
+ # --- listing ----------------------------------------------------------
258
+
259
+ @_tool
260
+ def list_dir(self, path: str = "") -> str:
261
+ """List a directory in the head worktree (shallow, hidden files skipped).
262
+
263
+ Args:
264
+ path: Path relative to repo root (empty for root).
265
+ """
266
+ full = (self.head_worktree / path).resolve() if path else self.head_worktree
267
+ if not _is_inside(full, self.head_worktree):
268
+ return f"error: path outside worktree: {path}"
269
+ if not full.is_dir():
270
+ return f"error: not a directory: {path}"
271
+ entries: list[str] = []
272
+ for p in sorted(full.iterdir()):
273
+ if p.name.startswith("."):
274
+ continue
275
+ marker = "/" if p.is_dir() else ""
276
+ entries.append(f"{p.name}{marker}")
277
+ return _cap("\n".join(entries))
278
+
279
+ # --- git --------------------------------------------------------------
280
+
281
+ @_tool
282
+ def git_log(self, path: str, limit: int = 5) -> str:
283
+ """Recent commits touching a path (short form).
284
+
285
+ Args:
286
+ path: Path relative to repo root.
287
+ limit: Maximum commits to return.
288
+ """
289
+ try:
290
+ return _cap(
291
+ git_ops.git(
292
+ self.repo_git,
293
+ "log",
294
+ f"-n{limit}",
295
+ "--oneline",
296
+ "--",
297
+ path,
298
+ )
299
+ )
300
+ except git_ops.GitError as e:
301
+ return f"error: {e}"
302
+
303
+ # --- diff (review console only) ---------------------------------------
304
+
305
+ def hunk(self, hunk_id: str) -> str:
306
+ """Read one hunk's diff text, addressed by its viewer id.
307
+
308
+ Returns the hunk's `@@` header followed by its body (the `+`/`-`/
309
+ context lines as they appear in the change under review). Use this
310
+ to pull the exact diff for a hunk the reviewer is asking about
311
+ rather than re-reading whole files.
312
+
313
+ Args:
314
+ hunk_id: Viewer hunk id of the form 'H<file_idx>_<hunk_idx>'.
315
+ """
316
+ if self.diff is None:
317
+ return "error: no diff bound — hunk() is only available in the review console"
318
+ try:
319
+ fi, hi = _parse_hunk_id(hunk_id)
320
+ except ValueError as e:
321
+ return f"error: {e}"
322
+ files = getattr(self.diff, "files", [])
323
+ if not (0 <= fi < len(files)):
324
+ return f"error: file index {fi} not in diff (hunk_id {hunk_id!r})"
325
+ hunks = files[fi].hunks
326
+ if not (0 <= hi < len(hunks)):
327
+ return f"error: hunk index {hi} not in file {files[fi].path!r} (hunk_id {hunk_id!r})"
328
+ parsed = hunks[hi].parsed
329
+ body = parsed.body or ""
330
+ text = parsed.header if not body else f"{parsed.header}\n{body}"
331
+ return _cap(f"# {files[fi].path}\n{text}")
332
+
333
+
334
+ def _parse_hunk_id(hunk_id: str) -> tuple[int, int]:
335
+ """`"H{fi}_{hi}"` -> (fi, hi). Raises ValueError on malformed input.
336
+
337
+ Mirrors `review/server.py`'s parser; duplicated rather than imported
338
+ to keep this module free of the stdlib-only server module.
339
+ """
340
+ if not hunk_id.startswith("H") or "_" not in hunk_id:
341
+ raise ValueError(f"malformed hunk_id {hunk_id!r}")
342
+ try:
343
+ fi_str, hi_str = hunk_id[1:].split("_", 1)
344
+ return int(fi_str), int(hi_str)
345
+ except ValueError as e:
346
+ raise ValueError(f"malformed hunk_id {hunk_id!r}") from e
347
+
348
+
349
+ def _is_inside(child: Path, parent: Path) -> bool:
350
+ try:
351
+ child.relative_to(parent.resolve())
352
+ return True
353
+ except ValueError:
354
+ return False
355
+
356
+
357
+ def _cap(text: str) -> str:
358
+ data = text.encode("utf-8")
359
+ if len(data) <= TOOL_RESULT_CAP_BYTES:
360
+ return text
361
+ cut = data[:TOOL_RESULT_CAP_BYTES].decode("utf-8", errors="replace")
362
+ return cut + "\n\n... [truncated — narrow your query] ..."
363
+
364
+
365
+ def _slice_and_cap(text: str, start_line: int | None, end_line: int | None) -> str:
366
+ if start_line is None and end_line is None:
367
+ return _cap(text)
368
+ lines = text.splitlines(keepends=True)
369
+ s = (start_line - 1) if start_line else 0
370
+ e = end_line if end_line else len(lines)
371
+ s = max(0, s)
372
+ e = min(len(lines), e)
373
+ return _cap("".join(lines[s:e]))
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Tool surface — derived from `RepoTools`
378
+ # ---------------------------------------------------------------------------
379
+ #
380
+ # Both the pydantic-ai Agent (`tools=TOOL_FUNCTIONS`) and the MCP stdio
381
+ # server (`mcp_tool_schemas`, `mcp_dispatch`) read from the same set of
382
+ # `@_tool`-marked methods. Adding/renaming a tool means editing one
383
+ # method — the wire surface follows.
384
+
385
+
386
+ def _exported_methods() -> list[tuple[str, Callable]]:
387
+ """Return `(name, func)` for each `@_tool`-marked method, in source order."""
388
+ out: list[tuple[str, Callable]] = []
389
+ for name, attr in vars(RepoTools).items():
390
+ if callable(attr) and getattr(attr, _TOOL_EXPORT_ATTR, False):
391
+ out.append((name, attr))
392
+ return out
393
+
394
+
395
+ def _make_tool_fn(method_name: str, method: Callable) -> Callable:
396
+ """Wrap a `RepoTools` method as a pydantic-ai-compatible tool function.
397
+
398
+ The returned callable takes `RunContext[RepoTools]` followed by the
399
+ method's parameters (minus `self`), and forwards to the matching
400
+ method on `ctx.deps`. Name, docstring, signature, and annotations
401
+ are copied so pydantic-ai's introspection produces the same schema
402
+ a hand-written wrapper would.
403
+ """
404
+ sig = inspect.signature(method)
405
+ method_params = list(sig.parameters.values())[1:] # drop self
406
+ ctx_param = inspect.Parameter(
407
+ "ctx",
408
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
409
+ annotation=RunContext[RepoTools],
410
+ )
411
+ new_sig = sig.replace(
412
+ parameters=[ctx_param, *method_params],
413
+ return_annotation=str,
414
+ )
415
+
416
+ async def fn(ctx: RunContext[RepoTools], **kwargs: Any) -> str:
417
+ return getattr(ctx.deps, method_name)(**kwargs)
418
+
419
+ fn.__name__ = method_name
420
+ fn.__qualname__ = method_name
421
+ fn.__doc__ = method.__doc__
422
+ fn.__signature__ = new_sig # type: ignore[attr-defined]
423
+ annotations: dict[str, Any] = {
424
+ p.name: p.annotation for p in (ctx_param, *method_params) if p.annotation is not inspect.Parameter.empty
425
+ }
426
+ annotations["return"] = str
427
+ fn.__annotations__ = annotations
428
+ return fn
429
+
430
+
431
+ TOOL_FUNCTIONS: list = [_make_tool_fn(n, m) for n, m in _exported_methods()]
432
+
433
+
434
+ def console_tool_functions() -> list:
435
+ """Tool surface for the review console: the shared `@_tool` surface
436
+ plus the console-only `hunk(id)` diff accessor.
437
+
438
+ `hunk` is deliberately *not* `@_tool`-marked — it needs a bound diff
439
+ that only exists once augmentation has produced the sidecar, so it
440
+ has no place on the augment-time per-hunk pass or the MCP server.
441
+ The console binds `RepoTools.diff` and wires this extended list as
442
+ its `tools=`.
443
+ """
444
+ return [*TOOL_FUNCTIONS, _make_tool_fn("hunk", RepoTools.hunk)]
445
+
446
+
447
+ def mcp_tool_schemas() -> list[dict[str, Any]]:
448
+ """MCP `tools/list` payload, derived from `TOOL_FUNCTIONS`."""
449
+ out: list[dict[str, Any]] = []
450
+ for fn in TOOL_FUNCTIONS:
451
+ tool = Tool(fn)
452
+ out.append(
453
+ {
454
+ "name": tool.name,
455
+ "description": tool.description or "",
456
+ "inputSchema": tool.function_schema.json_schema,
457
+ }
458
+ )
459
+ return out
460
+
461
+
462
+ def mcp_dispatch(repo_tools: RepoTools, name: str, args: dict[str, Any]) -> str:
463
+ """Run a tool by name against `repo_tools` for the MCP `tools/call` path.
464
+
465
+ Only methods marked with `@_tool` on `RepoTools` are reachable —
466
+ private helpers and dunder attrs are rejected.
467
+ """
468
+ method = getattr(repo_tools, name, None)
469
+ if not callable(method) or not getattr(method, _TOOL_EXPORT_ATTR, False):
470
+ return f"error: unknown tool {name!r}"
471
+ try:
472
+ # Exported tool methods return str; getattr erases that to object.
473
+ return cast("str", method(**args))
474
+ except TypeError as e:
475
+ return f"error: bad args for {name}: {e}"