codereview-loop 0.13.0__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.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
codereview/intake.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview intake — issue report in, solution plan out.
|
|
3
|
+
|
|
4
|
+
The front half of the workflow. Everything else in codereview
|
|
5
|
+
starts from a code change that already exists; intake starts from
|
|
6
|
+
the problem. An issue report (Markdown) plus optional context
|
|
7
|
+
files goes to one or more solver models, and each returns a
|
|
8
|
+
solution plan in the shape a builder can execute: what to check
|
|
9
|
+
first, ranked hypotheses — each with the test that settles it — a
|
|
10
|
+
concrete fix plan, prevention, risks and rollback, and closure
|
|
11
|
+
criteria a verifier can run.
|
|
12
|
+
|
|
13
|
+
The primary solver (the first configured seat that answers)
|
|
14
|
+
becomes solution.md; every other solver's plan is kept alongside
|
|
15
|
+
as an alternate for the builder to compare. Intake never writes
|
|
16
|
+
code and never gates anything: the builder reads the plan,
|
|
17
|
+
implements it, and the review loop gates the resulting change
|
|
18
|
+
exactly as before.
|
|
19
|
+
|
|
20
|
+
Report and context are secret-redacted before they leave the
|
|
21
|
+
machine and size-capped; every truncation is recorded in the
|
|
22
|
+
manifest (intake.json) and disclosed inside the prompt itself, so
|
|
23
|
+
a solver never mistakes a cut-off file for a complete one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
|
|
35
|
+
from .core import VERSION, redact_secrets
|
|
36
|
+
|
|
37
|
+
MAX_REPORT_CHARS = 60000
|
|
38
|
+
MAX_CONTEXT_FILE_CHARS = 40000
|
|
39
|
+
MAX_TOTAL_CONTEXT_CHARS = 120000
|
|
40
|
+
MAX_SOLVER_WORKERS = 8
|
|
41
|
+
DEFAULT_SOLVER_TIMEOUT = 300
|
|
42
|
+
|
|
43
|
+
_INSTRUCTIONS = """You are the solver in a software-maintenance workflow. A coding agent (the builder) will execute your plan on the real system, and an independent multi-model review will gate the resulting change. Your plan is the only guidance the builder gets, so it must be executable as written.
|
|
44
|
+
|
|
45
|
+
Write the solution plan in Markdown with exactly these sections:
|
|
46
|
+
|
|
47
|
+
# Solution — <short title naming the problem>
|
|
48
|
+
|
|
49
|
+
## Diagnosis — what to check first, in order
|
|
50
|
+
## Hypotheses — ranked most likely first; for each: the evidence for it, and the concrete test or observation that would prove or disprove it
|
|
51
|
+
## Fix plan — numbered steps the builder can execute in order; name files, functions, and commands when the material supports it
|
|
52
|
+
## Prevention — the guard, alarm, or test that keeps this from recurring silently
|
|
53
|
+
## Risks and rollback
|
|
54
|
+
## Closure criteria — a checklist a verifier can run to confirm the issue is actually closed
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Ground every claim in the issue report and context below. Label inferences as inferences; if you are guessing, say so.
|
|
58
|
+
- Do not invent file names, function names, line numbers, or log lines that are not in the material. Where the material is silent, make the missing check a diagnosis step instead of assuming its result.
|
|
59
|
+
- Prefer the smallest fix that addresses the root cause. Where two hypotheses imply different fixes, order the plan so the builder tests before committing to one.
|
|
60
|
+
- If the report shows this issue was already "fixed" before and recurred, treat the recurrence itself as the primary defect: the plan must explain why the earlier fix failed or was incomplete, not just repeat it."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cap(text, limit):
|
|
64
|
+
"""(sent_text, truncated) — cut with a disclosed marker."""
|
|
65
|
+
if len(text) <= limit:
|
|
66
|
+
return text, False
|
|
67
|
+
return text[:limit] + "\n\n[truncated by codereview intake: " \
|
|
68
|
+
f"{len(text) - limit} chars omitted]", True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_solver_prompt(report, contexts=(), repo_tree=""):
|
|
72
|
+
"""Assemble the solver prompt from already-redacted material.
|
|
73
|
+
|
|
74
|
+
Two cap layers, by design: _prepare caps the report and each
|
|
75
|
+
context file individually (and records it in the manifest);
|
|
76
|
+
this builder additionally enforces the total context budget
|
|
77
|
+
across files, disclosing any further cut in the prompt.
|
|
78
|
+
|
|
79
|
+
contexts: [(label, text)] — texts are per-file capped here; the
|
|
80
|
+
running total is capped as well and later files disclose it."""
|
|
81
|
+
parts = [_INSTRUCTIONS, "", "## Issue report", "", report]
|
|
82
|
+
if repo_tree:
|
|
83
|
+
parts += ["", "## Repository layout", "", repo_tree]
|
|
84
|
+
total = 0
|
|
85
|
+
for label, text in contexts:
|
|
86
|
+
sent, truncated = _cap(text, MAX_CONTEXT_FILE_CHARS)
|
|
87
|
+
if total + len(sent) > MAX_TOTAL_CONTEXT_CHARS:
|
|
88
|
+
sent, truncated = _cap(
|
|
89
|
+
text, max(0, MAX_TOTAL_CONTEXT_CHARS - total))
|
|
90
|
+
total += len(sent)
|
|
91
|
+
parts += ["", f"## Context: {label}", "", sent]
|
|
92
|
+
if truncated:
|
|
93
|
+
parts.append(f"[note: context {label} was truncated "
|
|
94
|
+
"to fit the prompt budget]")
|
|
95
|
+
return "\n".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _prepare(report, contexts, repo_tree=""):
|
|
99
|
+
"""Redact + cap the raw inputs; return (prompt, meta)."""
|
|
100
|
+
clean_report, rep_trunc = _cap(redact_secrets(report),
|
|
101
|
+
MAX_REPORT_CHARS)
|
|
102
|
+
clean_contexts = []
|
|
103
|
+
ctx_meta = []
|
|
104
|
+
for label, text in contexts:
|
|
105
|
+
clean = redact_secrets(text)
|
|
106
|
+
sent, truncated = _cap(clean, MAX_CONTEXT_FILE_CHARS)
|
|
107
|
+
clean_contexts.append((label, sent))
|
|
108
|
+
ctx_meta.append({"label": label, "chars": len(text),
|
|
109
|
+
"sent_chars": len(sent),
|
|
110
|
+
"truncated": truncated})
|
|
111
|
+
prompt = build_solver_prompt(clean_report, clean_contexts,
|
|
112
|
+
repo_tree=repo_tree)
|
|
113
|
+
meta = {
|
|
114
|
+
"report": {"chars": len(report), "sent_chars": len(clean_report),
|
|
115
|
+
"truncated": rep_trunc,
|
|
116
|
+
"sha256": hashlib.sha256(
|
|
117
|
+
report.encode("utf-8")).hexdigest()},
|
|
118
|
+
"contexts": ctx_meta,
|
|
119
|
+
"prompt_chars": len(prompt),
|
|
120
|
+
}
|
|
121
|
+
return prompt, meta
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_intake(report, solvers, contexts=(), repo_tree="",
|
|
125
|
+
timeout=DEFAULT_SOLVER_TIMEOUT):
|
|
126
|
+
"""Run every solver over the report in parallel.
|
|
127
|
+
|
|
128
|
+
solvers: {seat name: callable(prompt) -> markdown text}; dict
|
|
129
|
+
order is the configuration order and decides the primary.
|
|
130
|
+
A solver that has not answered within `timeout` seconds is
|
|
131
|
+
recorded as failed ("timed out") instead of blocking the run:
|
|
132
|
+
the provider HTTP layer already times out at 240s, and this
|
|
133
|
+
deadline is the outer guard for callables that ignore theirs.
|
|
134
|
+
Returns the intake result dict (see write_intake)."""
|
|
135
|
+
prompt, meta = _prepare(report, contexts, repo_tree=repo_tree)
|
|
136
|
+
|
|
137
|
+
def call(name, fn):
|
|
138
|
+
started = time.monotonic()
|
|
139
|
+
try:
|
|
140
|
+
text = fn(prompt)
|
|
141
|
+
return {"name": name, "ok": True,
|
|
142
|
+
"seconds": round(time.monotonic() - started, 1),
|
|
143
|
+
"chars": len(text or ""), "text": text or "",
|
|
144
|
+
"error": ""}
|
|
145
|
+
except Exception as exc: # noqa: BLE001 - solver failure is data
|
|
146
|
+
return {"name": name, "ok": False,
|
|
147
|
+
"seconds": round(time.monotonic() - started, 1),
|
|
148
|
+
"chars": 0, "text": "",
|
|
149
|
+
"error": str(exc)[:300]}
|
|
150
|
+
|
|
151
|
+
solutions = []
|
|
152
|
+
if solvers:
|
|
153
|
+
workers = max(1, min(len(solvers), MAX_SOLVER_WORKERS))
|
|
154
|
+
pool = ThreadPoolExecutor(max_workers=workers)
|
|
155
|
+
try:
|
|
156
|
+
futures = {name: pool.submit(call, name, fn)
|
|
157
|
+
for name, fn in solvers.items()}
|
|
158
|
+
deadline = time.monotonic() + timeout
|
|
159
|
+
by_name = {}
|
|
160
|
+
for name, fut in futures.items():
|
|
161
|
+
try:
|
|
162
|
+
by_name[name] = fut.result(
|
|
163
|
+
timeout=max(0.0, deadline - time.monotonic()))
|
|
164
|
+
except TimeoutError:
|
|
165
|
+
by_name[name] = {
|
|
166
|
+
"name": name, "ok": False, "seconds": timeout,
|
|
167
|
+
"chars": 0, "text": "",
|
|
168
|
+
"error": f"timed out after {timeout}s"}
|
|
169
|
+
solutions = [by_name[name] for name in solvers]
|
|
170
|
+
finally:
|
|
171
|
+
# Never block the run on a hung solver thread; a stray
|
|
172
|
+
# thread may outlive the call but holds no shared state.
|
|
173
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
174
|
+
primary = next((s["name"] for s in solutions if s["ok"]), None)
|
|
175
|
+
return {"tool": "codereview", "version": VERSION, "kind": "intake",
|
|
176
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
177
|
+
"ok": primary is not None, "primary": primary,
|
|
178
|
+
"solutions": solutions, **meta}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def write_intake(result, out_dir, report_name=""):
|
|
182
|
+
"""Write solution.md (primary), alternates, and intake.json.
|
|
183
|
+
|
|
184
|
+
Returns (solution_path or None, manifest_path)."""
|
|
185
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
186
|
+
manifest = {k: v for k, v in result.items() if k != "solutions"}
|
|
187
|
+
manifest["report"] = {**result["report"], "name": report_name}
|
|
188
|
+
manifest["solvers"] = [
|
|
189
|
+
{k: s[k] for k in ("name", "ok", "seconds", "chars", "error")}
|
|
190
|
+
for s in result["solutions"]]
|
|
191
|
+
solution_path = None
|
|
192
|
+
for s in result["solutions"]:
|
|
193
|
+
if not s["ok"]:
|
|
194
|
+
continue
|
|
195
|
+
header = (f"<!-- codereview intake {result['version']} · "
|
|
196
|
+
f"solver: {s['name']} · report: {report_name} · "
|
|
197
|
+
f"{result['created']} -->\n\n")
|
|
198
|
+
if s["name"] == result["primary"]:
|
|
199
|
+
solution_path = os.path.join(out_dir, "solution.md")
|
|
200
|
+
path = solution_path
|
|
201
|
+
else:
|
|
202
|
+
path = os.path.join(out_dir, f"solution-{s['name']}.md")
|
|
203
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
204
|
+
fh.write(header + s["text"].strip() + "\n")
|
|
205
|
+
manifest_path = os.path.join(out_dir, "intake.json")
|
|
206
|
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
207
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=1)
|
|
208
|
+
return solution_path, manifest_path
|
codereview/mcp_tools.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""MCP tool layer (Epic L2): the gate, served to other agents.
|
|
2
|
+
|
|
3
|
+
`codereview mcp serve` speaks the Model Context Protocol over
|
|
4
|
+
stdio (transport: codereview.mcp_transport) and exposes two
|
|
5
|
+
tools:
|
|
6
|
+
|
|
7
|
+
- `run_gate` — a diff (patch text, or a git range in a repo)
|
|
8
|
+
plus an optional policy path → the gate's verdict and the
|
|
9
|
+
run record's path;
|
|
10
|
+
- `get_verdict` — a stored record (file or directory) → the
|
|
11
|
+
verdict summary and findings.
|
|
12
|
+
|
|
13
|
+
Serving only, by design: the server is a new front door onto
|
|
14
|
+
the EXISTING gate, not a new gate. A run goes through the
|
|
15
|
+
CLI's own `main()` with the arguments translated, so a verdict
|
|
16
|
+
served here is the CLI's verdict on the same change — same
|
|
17
|
+
panel resolution, same saved-setup and environment discipline,
|
|
18
|
+
same record on disk. The review path itself consumes no MCP
|
|
19
|
+
tools and reviewers stay read-only and tool-less (Epic F2
|
|
20
|
+
unchanged).
|
|
21
|
+
|
|
22
|
+
The server holds no credentials of its own: seats come only
|
|
23
|
+
from the user's saved setup (or $CODEREVIEW_PROVIDERS), exactly
|
|
24
|
+
as for the CLI. With no setup, run_gate answers with the CLI's
|
|
25
|
+
own not-set-up message — there is no built-in panel to fall
|
|
26
|
+
back to, here or anywhere.
|
|
27
|
+
|
|
28
|
+
Fail-closed: a malformed tool call (wrong types, unknown
|
|
29
|
+
arguments, patch+range together) is refused with an error
|
|
30
|
+
result before anything runs; a refused or failed run is an
|
|
31
|
+
`isError` tool result carrying the reason, never an exception
|
|
32
|
+
escaping into the transport loop.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import io
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import tempfile
|
|
41
|
+
|
|
42
|
+
from . import __version__, hardening
|
|
43
|
+
from .mcp_transport import (INVALID_PARAMS, METHOD_NOT_FOUND,
|
|
44
|
+
JsonRpcError, StdioServer)
|
|
45
|
+
|
|
46
|
+
# Protocol versions this subset implements, newest first. The
|
|
47
|
+
# handshake echoes the client's version when it is one of
|
|
48
|
+
# these and answers with the newest otherwise (the MCP
|
|
49
|
+
# negotiation rule).
|
|
50
|
+
PROTOCOL_VERSIONS = ("2025-03-26", "2024-11-05")
|
|
51
|
+
|
|
52
|
+
SERVER_INFO = {"name": "codereview", "version": __version__}
|
|
53
|
+
|
|
54
|
+
TOOL_DEFS = [
|
|
55
|
+
{
|
|
56
|
+
"name": "run_gate",
|
|
57
|
+
"description": (
|
|
58
|
+
"Run the CodeReview gate on a change and return "
|
|
59
|
+
"the verdict plus the run record's path. Give "
|
|
60
|
+
"either `patch` (unified diff text) or `range` "
|
|
61
|
+
"(a git range such as HEAD~1..HEAD, evaluated in "
|
|
62
|
+
"`repo`); with neither, the repo's working-tree "
|
|
63
|
+
"change is reviewed, as with the CLI. Seats come "
|
|
64
|
+
"from the operator's saved CodeReview setup — "
|
|
65
|
+
"with no setup the call fails with the setup "
|
|
66
|
+
"instructions, exactly like the CLI."),
|
|
67
|
+
"inputSchema": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"repo": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "repository path (default: "
|
|
73
|
+
"the server's working "
|
|
74
|
+
"directory)",
|
|
75
|
+
},
|
|
76
|
+
"range": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": "git diff range, e.g. "
|
|
79
|
+
"main...HEAD",
|
|
80
|
+
},
|
|
81
|
+
"patch": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "unified diff text (mutually "
|
|
84
|
+
"exclusive with range)",
|
|
85
|
+
},
|
|
86
|
+
"policy": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "project policy file (TOML); "
|
|
89
|
+
"default: .codereview.toml "
|
|
90
|
+
"at the repo root, when "
|
|
91
|
+
"present",
|
|
92
|
+
},
|
|
93
|
+
"trust_repo_policy": {
|
|
94
|
+
"type": "boolean",
|
|
95
|
+
"description": "honour a policy discovered "
|
|
96
|
+
"in the reviewed repository "
|
|
97
|
+
"in full, including its hooks "
|
|
98
|
+
"and gate-weakening keys; "
|
|
99
|
+
"without it a discovered "
|
|
100
|
+
"policy keeps only its "
|
|
101
|
+
"strengthening keys (P3-01 "
|
|
102
|
+
"trust restriction) and the "
|
|
103
|
+
"dropped keys are returned as "
|
|
104
|
+
"policy_ignored_keys",
|
|
105
|
+
},
|
|
106
|
+
"out": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "output directory for the "
|
|
109
|
+
"run record (default: "
|
|
110
|
+
"<repo>/codereview-out)",
|
|
111
|
+
},
|
|
112
|
+
"context": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "builder's change "
|
|
115
|
+
"description, shown to the "
|
|
116
|
+
"reviewers",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
"additionalProperties": False,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "get_verdict",
|
|
124
|
+
"description": (
|
|
125
|
+
"Read a stored CodeReview run record and return "
|
|
126
|
+
"its verdict summary and findings. `record` is a "
|
|
127
|
+
"record JSON file or the directory holding it "
|
|
128
|
+
"(the newest timestamped record in the directory "
|
|
129
|
+
"wins, as with the CLI's record arguments)."),
|
|
130
|
+
"inputSchema": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {
|
|
133
|
+
"record": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"description": "record JSON file or "
|
|
136
|
+
"record directory",
|
|
137
|
+
},
|
|
138
|
+
"repo": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "repository the record "
|
|
141
|
+
"belongs to (default: the "
|
|
142
|
+
"server's working "
|
|
143
|
+
"directory)",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
"required": ["record"],
|
|
147
|
+
"additionalProperties": False,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ToolError(Exception):
|
|
154
|
+
"""A tool-level refusal or failure: returned to the client
|
|
155
|
+
as an isError tool result, never raised into the
|
|
156
|
+
transport."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_FINDING_FIELDS = ("id", "severity", "category", "file", "line",
|
|
160
|
+
"title", "recommendation", "confidence")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _finding_summary(finding):
|
|
164
|
+
return {key: finding.get(key) for key in _FINDING_FIELDS}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _verdict_payload(record, record_path):
|
|
168
|
+
"""The verdict shape both tools answer with: the gate,
|
|
169
|
+
its reasons, the findings (summarised — the full record
|
|
170
|
+
stays on disk at record_path), and severity counts.
|
|
171
|
+
|
|
172
|
+
Policy visibility (P3-01 follow-ups): the payload also
|
|
173
|
+
carries the record's policy origin, its self-modification
|
|
174
|
+
flag, and — since the trust restriction landed — the keys
|
|
175
|
+
dropped from a repo-discovered policy. The CLI's stderr
|
|
176
|
+
warning is captured and discarded on a successful
|
|
177
|
+
run_gate, so without these fields an MCP client could not
|
|
178
|
+
tell that the verdict was produced under a policy
|
|
179
|
+
discovered in the reviewed repo, which of its keys were
|
|
180
|
+
ignored, or that the change touched the policy file
|
|
181
|
+
itself. The keys are always present (null / [] when the
|
|
182
|
+
run had no policy section) so clients can rely on the
|
|
183
|
+
shape."""
|
|
184
|
+
findings = [_finding_summary(f)
|
|
185
|
+
for f in record.get("findings") or []]
|
|
186
|
+
by_severity = {}
|
|
187
|
+
for finding in findings:
|
|
188
|
+
sev = finding["severity"] or "unknown"
|
|
189
|
+
by_severity[sev] = by_severity.get(sev, 0) + 1
|
|
190
|
+
policy = record.get("policy") or {}
|
|
191
|
+
return {
|
|
192
|
+
"gate": record.get("gate"),
|
|
193
|
+
"gate_reasons": record.get("gate_reasons") or [],
|
|
194
|
+
"mode": record.get("mode"),
|
|
195
|
+
"degraded": bool(record.get("degraded")),
|
|
196
|
+
"policy_origin": policy.get("origin"),
|
|
197
|
+
"policy_ignored_keys": policy.get("ignored_keys") or [],
|
|
198
|
+
"policy_self_modification":
|
|
199
|
+
record.get("policy_self_modification") or [],
|
|
200
|
+
"record_path": record_path,
|
|
201
|
+
"findings": findings,
|
|
202
|
+
"counts": {
|
|
203
|
+
"total": len(findings),
|
|
204
|
+
"blocking": by_severity.get("blocker", 0)
|
|
205
|
+
+ by_severity.get("major", 0),
|
|
206
|
+
"by_severity": by_severity,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class GateTools:
|
|
212
|
+
"""The two gate tools. The *_override seams are the CLI's
|
|
213
|
+
own (tests and internal runners inject stub seats through
|
|
214
|
+
them); a production server is constructed with none, so
|
|
215
|
+
seats resolve from the saved setup / environment exactly
|
|
216
|
+
as a CLI run's do."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, providers_override=None,
|
|
219
|
+
seat_meta_override=None, fallbacks_override=None):
|
|
220
|
+
self._providers_override = providers_override
|
|
221
|
+
self._seat_meta_override = seat_meta_override
|
|
222
|
+
self._fallbacks_override = fallbacks_override
|
|
223
|
+
|
|
224
|
+
# -- argument discipline -------------------------------------
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _checked_args(tool, arguments):
|
|
228
|
+
if arguments is None:
|
|
229
|
+
arguments = {}
|
|
230
|
+
if not isinstance(arguments, dict):
|
|
231
|
+
raise ToolError(
|
|
232
|
+
f"{tool}: arguments must be an object")
|
|
233
|
+
schema = next(t["inputSchema"] for t in TOOL_DEFS
|
|
234
|
+
if t["name"] == tool)
|
|
235
|
+
allowed = schema["properties"]
|
|
236
|
+
for key, value in arguments.items():
|
|
237
|
+
if key not in allowed:
|
|
238
|
+
raise ToolError(
|
|
239
|
+
f"{tool}: unknown argument {key!r} "
|
|
240
|
+
f"(allowed: {', '.join(sorted(allowed))})")
|
|
241
|
+
if allowed[key].get("type") == "boolean":
|
|
242
|
+
if not isinstance(value, bool):
|
|
243
|
+
raise ToolError(
|
|
244
|
+
f"{tool}: argument {key!r} must be "
|
|
245
|
+
f"a boolean")
|
|
246
|
+
elif not isinstance(value, str):
|
|
247
|
+
raise ToolError(
|
|
248
|
+
f"{tool}: argument {key!r} must be a string")
|
|
249
|
+
for key in schema.get("required", []):
|
|
250
|
+
if key not in arguments:
|
|
251
|
+
raise ToolError(
|
|
252
|
+
f"{tool}: missing required argument {key!r}")
|
|
253
|
+
return arguments
|
|
254
|
+
|
|
255
|
+
# -- run_gate --------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def run_gate(self, arguments):
|
|
258
|
+
from . import cli, userconfig
|
|
259
|
+
args = self._checked_args("run_gate", arguments)
|
|
260
|
+
if args.get("patch") and args.get("range"):
|
|
261
|
+
raise ToolError(
|
|
262
|
+
"run_gate: give either patch or range, not both")
|
|
263
|
+
# P3-09: same repo validation as the CLI — an
|
|
264
|
+
# explicitly given repo must be an existing directory;
|
|
265
|
+
# "" must not silently become the server's CWD and a
|
|
266
|
+
# nonexistent path must not silently skip the project
|
|
267
|
+
# context in patch mode.
|
|
268
|
+
if "repo" in args and (not args["repo"]
|
|
269
|
+
or not os.path.isdir(args["repo"])):
|
|
270
|
+
raise ToolError(
|
|
271
|
+
f"run_gate: repo {args['repo']!r} is not an "
|
|
272
|
+
f"existing directory")
|
|
273
|
+
repo = args.get("repo") or "."
|
|
274
|
+
out = args.get("out") or os.path.join(
|
|
275
|
+
repo, "codereview-out")
|
|
276
|
+
argv = ["--repo", repo, "--out", out]
|
|
277
|
+
if args.get("range"):
|
|
278
|
+
argv += ["--range", args["range"]]
|
|
279
|
+
if args.get("policy"):
|
|
280
|
+
argv += ["--policy", args["policy"]]
|
|
281
|
+
if args.get("trust_repo_policy"):
|
|
282
|
+
argv += ["--trust-repo-policy"]
|
|
283
|
+
if args.get("context"):
|
|
284
|
+
argv += ["--context", args["context"]]
|
|
285
|
+
patch_file = None
|
|
286
|
+
if args.get("patch") is not None:
|
|
287
|
+
fd, patch_file = tempfile.mkstemp(
|
|
288
|
+
prefix="codereview-mcp-", suffix=".patch")
|
|
289
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
290
|
+
fh.write(args["patch"])
|
|
291
|
+
argv += ["--patch", patch_file]
|
|
292
|
+
stdout, stderr = io.StringIO(), io.StringIO()
|
|
293
|
+
try:
|
|
294
|
+
with contextlib.redirect_stdout(stdout), \
|
|
295
|
+
contextlib.redirect_stderr(stderr):
|
|
296
|
+
rc = cli.main(
|
|
297
|
+
argv,
|
|
298
|
+
providers_override=self._providers_override,
|
|
299
|
+
seat_meta_override=self._seat_meta_override,
|
|
300
|
+
fallbacks_override=self._fallbacks_override)
|
|
301
|
+
except SystemExit as exc:
|
|
302
|
+
raise ToolError(
|
|
303
|
+
"run_gate: the gate refused the request "
|
|
304
|
+
f"(exit {exc.code}): "
|
|
305
|
+
f"{_tail(stderr.getvalue())}") from exc
|
|
306
|
+
except Exception as exc: # noqa: BLE001 — fail closed
|
|
307
|
+
raise ToolError(
|
|
308
|
+
f"run_gate: the gate run failed: {exc}") from exc
|
|
309
|
+
finally:
|
|
310
|
+
if patch_file is not None:
|
|
311
|
+
try:
|
|
312
|
+
os.unlink(patch_file)
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
out_text, err_text = stdout.getvalue(), stderr.getvalue()
|
|
316
|
+
jpath = _printed_path(out_text, "data")
|
|
317
|
+
if jpath is None:
|
|
318
|
+
if userconfig.NOT_SET_UP.splitlines()[0] in err_text:
|
|
319
|
+
raise ToolError(err_text.strip())
|
|
320
|
+
raise ToolError(
|
|
321
|
+
f"run_gate: the gate run produced no record "
|
|
322
|
+
f"(exit {rc}): {_tail(err_text) or _tail(out_text)}")
|
|
323
|
+
try:
|
|
324
|
+
with open(jpath, encoding="utf-8") as fh:
|
|
325
|
+
record = json.load(fh)
|
|
326
|
+
except Exception as exc: # noqa: BLE001
|
|
327
|
+
raise ToolError(
|
|
328
|
+
f"run_gate: could not read the record the run "
|
|
329
|
+
f"wrote ({jpath}): {exc}") from exc
|
|
330
|
+
payload = _verdict_payload(record, jpath)
|
|
331
|
+
payload["report_path"] = _printed_path(out_text, "report")
|
|
332
|
+
payload["exit_code"] = rc
|
|
333
|
+
return payload
|
|
334
|
+
|
|
335
|
+
# -- get_verdict ------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def get_verdict(self, arguments):
|
|
338
|
+
from . import cli
|
|
339
|
+
args = self._checked_args("get_verdict", arguments)
|
|
340
|
+
try:
|
|
341
|
+
record, _record_dir, jpath = cli._load_record_arg(
|
|
342
|
+
args["record"], args.get("repo") or ".")
|
|
343
|
+
except Exception as exc: # noqa: BLE001
|
|
344
|
+
raise ToolError(
|
|
345
|
+
f"get_verdict: could not read the record: "
|
|
346
|
+
f"{exc}") from exc
|
|
347
|
+
return _verdict_payload(record, jpath)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _printed_path(text, label):
|
|
351
|
+
"""The CLI prints ' report: <path>' / ' data: <path>'
|
|
352
|
+
lines at the end of a run; pull one back out."""
|
|
353
|
+
matches = re.findall(rf"^ {label}:\s+(\S.*)$", text,
|
|
354
|
+
flags=re.MULTILINE)
|
|
355
|
+
return matches[-1].strip() if matches else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _tail(text, limit=800):
|
|
359
|
+
text = (text or "").strip()
|
|
360
|
+
return text[-limit:] if len(text) > limit else text
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# -- protocol handling --------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _call_result(payload):
|
|
367
|
+
return {
|
|
368
|
+
"content": [{
|
|
369
|
+
"type": "text",
|
|
370
|
+
"text": json.dumps(payload, ensure_ascii=False,
|
|
371
|
+
indent=2),
|
|
372
|
+
}],
|
|
373
|
+
"structuredContent": payload,
|
|
374
|
+
"isError": False,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _error_result(message):
|
|
379
|
+
return {
|
|
380
|
+
"content": [{"type": "text", "text": message}],
|
|
381
|
+
"isError": True,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def make_dispatch(tools):
|
|
386
|
+
"""Build the transport dispatch callable for a GateTools
|
|
387
|
+
instance: MCP method handling (initialize / ping /
|
|
388
|
+
tools/list / tools/call) on top of the wire layer."""
|
|
389
|
+
|
|
390
|
+
def dispatch(method, params, notification):
|
|
391
|
+
if notification:
|
|
392
|
+
# notifications/initialized, notifications/
|
|
393
|
+
# cancelled, anything else: notifications are
|
|
394
|
+
# fire-and-forget and never answered.
|
|
395
|
+
return None
|
|
396
|
+
if method == "initialize":
|
|
397
|
+
params = params if isinstance(params, dict) else {}
|
|
398
|
+
requested = params.get("protocolVersion")
|
|
399
|
+
version = requested if requested in PROTOCOL_VERSIONS \
|
|
400
|
+
else PROTOCOL_VERSIONS[0]
|
|
401
|
+
return {
|
|
402
|
+
"protocolVersion": version,
|
|
403
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
404
|
+
"serverInfo": dict(SERVER_INFO),
|
|
405
|
+
"instructions": (
|
|
406
|
+
"CodeReview gate. run_gate runs the "
|
|
407
|
+
"multi-model review gate on a diff and "
|
|
408
|
+
"returns the verdict + record path; "
|
|
409
|
+
"get_verdict reads a stored run record."),
|
|
410
|
+
}
|
|
411
|
+
if method == "ping":
|
|
412
|
+
return {}
|
|
413
|
+
if method == "tools/list":
|
|
414
|
+
return {"tools": TOOL_DEFS}
|
|
415
|
+
if method == "tools/call":
|
|
416
|
+
if not isinstance(params, dict) \
|
|
417
|
+
or not isinstance(params.get("name"), str):
|
|
418
|
+
raise JsonRpcError(
|
|
419
|
+
INVALID_PARAMS,
|
|
420
|
+
"tools/call needs a params object with a "
|
|
421
|
+
"tool name")
|
|
422
|
+
name = params["name"]
|
|
423
|
+
handler = {"run_gate": tools.run_gate,
|
|
424
|
+
"get_verdict": tools.get_verdict}.get(name)
|
|
425
|
+
if handler is None:
|
|
426
|
+
raise JsonRpcError(
|
|
427
|
+
INVALID_PARAMS, f"unknown tool: {name}")
|
|
428
|
+
try:
|
|
429
|
+
payload = handler(params.get("arguments"))
|
|
430
|
+
except ToolError as exc:
|
|
431
|
+
return _error_result(str(exc))
|
|
432
|
+
return _call_result(payload)
|
|
433
|
+
raise JsonRpcError(
|
|
434
|
+
METHOD_NOT_FOUND, f"method not found: {method}")
|
|
435
|
+
|
|
436
|
+
return dispatch
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def serve_stdio(tools=None, stdin=None, stdout=None, log=None,
|
|
440
|
+
max_frame_bytes=None):
|
|
441
|
+
"""Assemble the server (transport + tools) and serve until
|
|
442
|
+
EOF. Returns the StdioServer so callers (and tests) can
|
|
443
|
+
inspect its error ledger."""
|
|
444
|
+
# P3-04: the server process holds the operator's
|
|
445
|
+
# environment (provider keys) and spawns gate runs; make
|
|
446
|
+
# it non-dumpable before serving.
|
|
447
|
+
hardening.make_non_dumpable()
|
|
448
|
+
tools = tools if tools is not None else GateTools()
|
|
449
|
+
kwargs = {}
|
|
450
|
+
if max_frame_bytes is not None:
|
|
451
|
+
kwargs["max_frame_bytes"] = max_frame_bytes
|
|
452
|
+
server = StdioServer(make_dispatch(tools), stdin=stdin,
|
|
453
|
+
stdout=stdout, log=log, **kwargs)
|
|
454
|
+
server.serve_forever()
|
|
455
|
+
return server
|