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/cli.py
ADDED
|
@@ -0,0 +1,2927 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview CLI.
|
|
3
|
+
|
|
4
|
+
Examples:
|
|
5
|
+
codereview --repo . --staged --provider gemini --provider openai
|
|
6
|
+
codereview --patch change.diff --provider xai --provider gemini \
|
|
7
|
+
--test-command "python -m pytest -q"
|
|
8
|
+
codereview --patch fix.diff --verify-previous report.json \
|
|
9
|
+
--builder-response response.md --provider gemini --provider nvidia
|
|
10
|
+
codereview loop --repo . --builder-cmd "your-coding-agent" \
|
|
11
|
+
--provider gemini --provider gemini --provider nvidia
|
|
12
|
+
codereview workflow --file workflow.json --repo . --out wf-out
|
|
13
|
+
codereview pr --repo OWNER/REPO --number 123 --provider gemini
|
|
14
|
+
codereview pr --pr https://github.com/OWNER/REPO/pull/123 --post
|
|
15
|
+
codereview intake issue-report.md --provider gemini \
|
|
16
|
+
--provider nvidia --context src/alerts.py
|
|
17
|
+
codereview sarif review-out --out results.sarif
|
|
18
|
+
codereview policy check # validate .codereview.toml and
|
|
19
|
+
# print the effective project policy
|
|
20
|
+
codereview evidence --repo . --config evidence.json
|
|
21
|
+
codereview evidence --repo . --claims builder-report.md
|
|
22
|
+
|
|
23
|
+
Exit codes: 0 = gate PASS (intake: a solution was produced),
|
|
24
|
+
1 = gate BLOCKED, 2 = INCONCLUSIVE or usage/infrastructure error
|
|
25
|
+
(intake: every solver failed).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
|
|
38
|
+
import inspect
|
|
39
|
+
|
|
40
|
+
from . import agentsmd
|
|
41
|
+
from . import bench as bench_mod
|
|
42
|
+
from . import claims as claims_mod
|
|
43
|
+
from . import core
|
|
44
|
+
from . import crash as crash_mod
|
|
45
|
+
from . import disputes as disputes_mod
|
|
46
|
+
from . import hardening
|
|
47
|
+
from . import herdrhost
|
|
48
|
+
from . import hooks as hooks_mod
|
|
49
|
+
from . import notify as notify_mod
|
|
50
|
+
from . import policy as policy_mod
|
|
51
|
+
from . import evidence as evidence_mod
|
|
52
|
+
from . import registry
|
|
53
|
+
from . import sarif as sarif_mod
|
|
54
|
+
from . import setup_wizard, userconfig
|
|
55
|
+
from .providers import (ProviderError, build_providers,
|
|
56
|
+
describe_spec)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_tests(commands, cwd):
|
|
60
|
+
# shell=True is deliberate: test commands are operator-supplied
|
|
61
|
+
# shell strings (the same trust level as a CI config the operator
|
|
62
|
+
# wrote). They are never taken from the diff under review.
|
|
63
|
+
results = []
|
|
64
|
+
for cmd in commands or []:
|
|
65
|
+
try:
|
|
66
|
+
out = subprocess.run(cmd, shell=True, cwd=cwd,
|
|
67
|
+
capture_output=True, timeout=1800,
|
|
68
|
+
check=False)
|
|
69
|
+
tail = (out.stdout + out.stderr).decode(
|
|
70
|
+
"utf-8", errors="replace")[-4000:]
|
|
71
|
+
results.append({"command": cmd, "ok": out.returncode == 0,
|
|
72
|
+
"exit_code": out.returncode,
|
|
73
|
+
"output_tail": tail})
|
|
74
|
+
except subprocess.TimeoutExpired:
|
|
75
|
+
results.append({"command": cmd, "ok": False,
|
|
76
|
+
"exit_code": None,
|
|
77
|
+
"output_tail": "timed out"})
|
|
78
|
+
return results
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_patch(args):
|
|
82
|
+
if args.patch:
|
|
83
|
+
with open(os.path.expanduser(args.patch), encoding="utf-8",
|
|
84
|
+
errors="replace") as fh:
|
|
85
|
+
return fh.read()
|
|
86
|
+
repo = args.repo or "."
|
|
87
|
+
if args.files:
|
|
88
|
+
return core.build_pseudo_patch(args.files, root=repo)
|
|
89
|
+
return core.collect_git_patch(repo, range_spec=args.range,
|
|
90
|
+
staged=args.staged)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The shared containment helper lives in core (P3-11: the
|
|
94
|
+
# CLI loaders, core's file-context collector and the
|
|
95
|
+
# AGENTS.md collector use ONE implementation). This alias
|
|
96
|
+
# keeps the historical cli._is_within name working.
|
|
97
|
+
_is_within = core._is_within
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_fixture_scope(path, repo="."):
|
|
101
|
+
"""Load a versioned fixture manifest (clean-gate solution §3):
|
|
102
|
+
returns the fixture_scope dict run_review records and
|
|
103
|
+
record-verify recomputes — manifest path (repo-relative),
|
|
104
|
+
content hash of the exact bytes, policy version, and the
|
|
105
|
+
enumerated fixture paths. The manifest declares which paths
|
|
106
|
+
are intentionally vulnerable test DATA; a review run only
|
|
107
|
+
classifies fixture findings when the operator passes one."""
|
|
108
|
+
repo_abs = os.path.realpath(repo)
|
|
109
|
+
full = os.path.realpath(
|
|
110
|
+
path if os.path.isabs(path)
|
|
111
|
+
else os.path.join(repo_abs, path))
|
|
112
|
+
if not _is_within(full, repo_abs):
|
|
113
|
+
# The recorded manifest path is replayed by record-verify
|
|
114
|
+
# as a repo-relative object name; a manifest outside the
|
|
115
|
+
# repo could neither be recorded honestly nor verified.
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"fixture manifest {path}: resolves outside the "
|
|
118
|
+
f"repository ({full})")
|
|
119
|
+
with open(full, "rb") as fh:
|
|
120
|
+
raw = fh.read()
|
|
121
|
+
manifest = json.loads(raw.decode("utf-8"))
|
|
122
|
+
if not isinstance(manifest, dict) \
|
|
123
|
+
or manifest.get("schema") != core.FIXTURE_MANIFEST_SCHEMA:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"fixture manifest {path}: schema must be "
|
|
126
|
+
f"{core.FIXTURE_MANIFEST_SCHEMA!r}")
|
|
127
|
+
if manifest.get("policy_version") \
|
|
128
|
+
not in core.FIXTURE_POLICY_VERSIONS:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"fixture manifest {path}: unsupported policy_version "
|
|
131
|
+
f"{manifest.get('policy_version')!r} (supported: "
|
|
132
|
+
f"{core.FIXTURE_POLICY_VERSIONS})")
|
|
133
|
+
entries = manifest.get("fixtures")
|
|
134
|
+
if not isinstance(entries, list) or not entries or any(
|
|
135
|
+
not isinstance(e, dict)
|
|
136
|
+
or not str(e.get("path") or "").strip()
|
|
137
|
+
for e in entries):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"fixture manifest {path}: 'fixtures' must be a "
|
|
140
|
+
f"non-empty list of objects each naming a 'path'")
|
|
141
|
+
patterns = [str(e["path"]) for e in entries]
|
|
142
|
+
# Optional per-path expected finding counts (v0.9.3, §2f):
|
|
143
|
+
# when an entry declares one, findings on that path beyond
|
|
144
|
+
# the count are NOT classified as expected — they stay in
|
|
145
|
+
# the record as product findings and the excess is noted.
|
|
146
|
+
# Absent = no count expectation (the policy-v1 behaviour).
|
|
147
|
+
expected_counts = {}
|
|
148
|
+
for e in entries:
|
|
149
|
+
if "expected_finding_count" in e:
|
|
150
|
+
v = e.get("expected_finding_count")
|
|
151
|
+
if not isinstance(v, int) or isinstance(v, bool) or v < 0:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"fixture manifest {path}: entry "
|
|
154
|
+
f"{e.get('path')!r} has a bad "
|
|
155
|
+
f"expected_finding_count {v!r} (want a "
|
|
156
|
+
f"non-negative integer)")
|
|
157
|
+
expected_counts[str(e["path"])] = v
|
|
158
|
+
return {
|
|
159
|
+
"manifest_path": os.path.relpath(full, repo_abs).replace(
|
|
160
|
+
os.sep, "/"),
|
|
161
|
+
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
|
|
162
|
+
"policy_version": manifest.get("policy_version"),
|
|
163
|
+
"patterns": patterns,
|
|
164
|
+
"expected_counts": expected_counts,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_precedents(path, repo="."):
|
|
169
|
+
"""Load the content-addressed precedent store (v0.9.3, §2e):
|
|
170
|
+
returns the precedents dict run_review consumes — entries,
|
|
171
|
+
a blob_lookup resolving a cited path's CURRENT git blob id
|
|
172
|
+
at HEAD, a source_lookup resolving its CURRENT text at HEAD
|
|
173
|
+
(for function-level entries, §5d), and the store's
|
|
174
|
+
repo-relative path + content hash for the record
|
|
175
|
+
(record-verify recomputes both, exactly like the fixture
|
|
176
|
+
manifest). The store declares findings already adjudicated;
|
|
177
|
+
classification additionally requires the cited file's blob
|
|
178
|
+
to be unchanged, so a stale store entry is inert, never
|
|
179
|
+
wrong."""
|
|
180
|
+
repo_abs = os.path.realpath(repo)
|
|
181
|
+
full = os.path.realpath(
|
|
182
|
+
path if os.path.isabs(path)
|
|
183
|
+
else os.path.join(repo_abs, path))
|
|
184
|
+
if not _is_within(full, repo_abs):
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"precedent store {path}: resolves outside the "
|
|
187
|
+
f"repository ({full})")
|
|
188
|
+
with open(full, "rb") as fh:
|
|
189
|
+
raw = fh.read()
|
|
190
|
+
store = json.loads(raw.decode("utf-8"))
|
|
191
|
+
if not isinstance(store, dict) \
|
|
192
|
+
or store.get("schema") != core.PRECEDENTS_SCHEMA:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"precedent store {path}: schema must be "
|
|
195
|
+
f"{core.PRECEDENTS_SCHEMA!r}")
|
|
196
|
+
entries = store.get("entries")
|
|
197
|
+
if not isinstance(entries, list) or any(
|
|
198
|
+
not isinstance(e, dict)
|
|
199
|
+
or not str(e.get("finding_id") or "").strip()
|
|
200
|
+
or not str(e.get("path") or "").strip()
|
|
201
|
+
or not (str(e.get("blob") or "").strip()
|
|
202
|
+
or (str(e.get("function") or "").strip()
|
|
203
|
+
and str(e.get("function_hash")
|
|
204
|
+
or "").strip()))
|
|
205
|
+
for e in entries):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"precedent store {path}: 'entries' must be a list "
|
|
208
|
+
f"of objects each naming finding_id, path and a "
|
|
209
|
+
f"content address (blob, or function + "
|
|
210
|
+
f"function_hash for a function-level entry, §5d)")
|
|
211
|
+
|
|
212
|
+
def blob_lookup(p):
|
|
213
|
+
out = subprocess.run(
|
|
214
|
+
["git", "-C", repo_abs, "rev-parse", f"HEAD:{p}"],
|
|
215
|
+
capture_output=True, timeout=120, check=False)
|
|
216
|
+
if out.returncode != 0:
|
|
217
|
+
return None
|
|
218
|
+
return out.stdout.decode("utf-8", "replace").strip() or None
|
|
219
|
+
|
|
220
|
+
def source_lookup(p):
|
|
221
|
+
out = subprocess.run(
|
|
222
|
+
["git", "-C", repo_abs, "show", f"HEAD:{p}"],
|
|
223
|
+
capture_output=True, timeout=120, check=False)
|
|
224
|
+
if out.returncode != 0:
|
|
225
|
+
return None
|
|
226
|
+
return out.stdout.decode("utf-8", "replace")
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"entries": entries,
|
|
230
|
+
"blob_lookup": blob_lookup,
|
|
231
|
+
"source_lookup": source_lookup,
|
|
232
|
+
"store_path": os.path.relpath(full, repo_abs).replace(
|
|
233
|
+
os.sep, "/"),
|
|
234
|
+
"store_sha256": hashlib.sha256(raw).hexdigest(),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _stdin_is_tty():
|
|
239
|
+
try:
|
|
240
|
+
return bool(sys.stdin.isatty())
|
|
241
|
+
except Exception: # noqa: BLE001 — no stdin, closed stdin
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _probe_writable(path):
|
|
246
|
+
"""Prove `path` can be written into, or return the OSError
|
|
247
|
+
that says it cannot (P5-07b): create the directory chain
|
|
248
|
+
and a temp file for real, then remove them. The Phase 5
|
|
249
|
+
check inferred writability from os.access, which lies for
|
|
250
|
+
a root user on /proc and /sys — the full review ran and
|
|
251
|
+
the late write crashed, discarding the result. A real
|
|
252
|
+
create cannot lie: it fails for root exactly as it fails
|
|
253
|
+
for anyone else. The side effect is brief — the temp file
|
|
254
|
+
is unlinked and any directories the probe created are
|
|
255
|
+
removed again (deepest first, best effort)."""
|
|
256
|
+
missing = []
|
|
257
|
+
probe = path
|
|
258
|
+
while not os.path.exists(probe):
|
|
259
|
+
missing.append(probe)
|
|
260
|
+
probe = os.path.dirname(probe)
|
|
261
|
+
try:
|
|
262
|
+
os.makedirs(path, exist_ok=True)
|
|
263
|
+
fd, tmpname = tempfile.mkstemp(
|
|
264
|
+
dir=path, prefix=".codereview-probe-")
|
|
265
|
+
try:
|
|
266
|
+
os.close(fd)
|
|
267
|
+
finally:
|
|
268
|
+
os.unlink(tmpname)
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
return exc
|
|
271
|
+
finally:
|
|
272
|
+
for created in missing: # deepest first (walk order)
|
|
273
|
+
try:
|
|
274
|
+
os.rmdir(created)
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _out_dir_problem(out):
|
|
281
|
+
"""Why the --out directory cannot be written into, or None
|
|
282
|
+
(P5-07). The check runs at argument-validation time so an
|
|
283
|
+
unwritable --out fails BEFORE any provider call, instead
|
|
284
|
+
of after the review has spent them (when the late crash
|
|
285
|
+
also lost the result). Writability is PROVEN by
|
|
286
|
+
_probe_writable, not inferred from os.access (P5-07b);
|
|
287
|
+
os.access is still consulted first so a plainly unwritable
|
|
288
|
+
ancestor is reported without creating anything."""
|
|
289
|
+
if not out:
|
|
290
|
+
return None
|
|
291
|
+
path = os.path.abspath(os.path.expanduser(out))
|
|
292
|
+
probe = path
|
|
293
|
+
while not os.path.exists(probe):
|
|
294
|
+
parent = os.path.dirname(probe)
|
|
295
|
+
if parent == probe:
|
|
296
|
+
return None
|
|
297
|
+
probe = parent
|
|
298
|
+
if not os.path.isdir(probe):
|
|
299
|
+
return f"--out {out}: {probe} is not a directory"
|
|
300
|
+
if not os.access(probe, os.W_OK | os.X_OK):
|
|
301
|
+
if probe == path:
|
|
302
|
+
return f"--out {out}: directory is not writable"
|
|
303
|
+
return (f"--out {out}: cannot be created — "
|
|
304
|
+
f"{probe} is not writable")
|
|
305
|
+
exc = _probe_writable(path)
|
|
306
|
+
if exc is not None:
|
|
307
|
+
return (f"--out {out}: cannot be written "
|
|
308
|
+
f"({exc.strerror or exc})")
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _refuse_unwritable_out(out, prog):
|
|
313
|
+
"""Print the P5-07 refusal and return True when --out is
|
|
314
|
+
unusable, so the caller exits 2 before doing any work."""
|
|
315
|
+
problem = _out_dir_problem(out)
|
|
316
|
+
if problem:
|
|
317
|
+
print(f"{prog}: {problem}", file=sys.stderr)
|
|
318
|
+
return True
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _ReportWriteFailed(Exception):
|
|
323
|
+
"""The review completed but writing its report failed
|
|
324
|
+
(P5-07b): _write_report has already printed the verdict
|
|
325
|
+
and preserved the record — the caller just exits 2."""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_report(report, out_dir, prog, stem=None):
|
|
329
|
+
"""core.write_report with the P5-07b late-failure net.
|
|
330
|
+
|
|
331
|
+
The up-front --out probe cannot see a disk that fills, a
|
|
332
|
+
permission change or a mount that goes read-only DURING
|
|
333
|
+
the review. When the write then raises OSError, the
|
|
334
|
+
completed review must not be discarded with a traceback:
|
|
335
|
+
the verdict is printed to stdout, the record is written
|
|
336
|
+
to a fallback location (the current directory, then the
|
|
337
|
+
temp directory, then — last resort — stdout itself as
|
|
338
|
+
JSON), and _ReportWriteFailed is raised so the caller
|
|
339
|
+
exits 2. Non-OSError failures (e.g. record-schema
|
|
340
|
+
validation) are NOT caught: they are defects, not
|
|
341
|
+
environment failures, and a fallback write would fail
|
|
342
|
+
identically."""
|
|
343
|
+
try:
|
|
344
|
+
return core.write_report(report, out_dir, stem=stem)
|
|
345
|
+
except OSError as exc:
|
|
346
|
+
print(f"{prog}: report write to {out_dir} failed: "
|
|
347
|
+
f"{exc}", file=sys.stderr)
|
|
348
|
+
gate = str(report.get("gate") or "inconclusive").upper()
|
|
349
|
+
reasons = "; ".join(report.get("gate_reasons") or [])
|
|
350
|
+
print(f"{prog}: gate = {gate} ({reasons})")
|
|
351
|
+
saved = None
|
|
352
|
+
for fallback in (os.getcwd(), tempfile.gettempdir()):
|
|
353
|
+
try:
|
|
354
|
+
saved, _mpath = core.write_report(
|
|
355
|
+
report, fallback, stem=stem)
|
|
356
|
+
break
|
|
357
|
+
except OSError:
|
|
358
|
+
continue
|
|
359
|
+
if saved is not None:
|
|
360
|
+
print(f"{prog}: record saved to fallback: {saved}")
|
|
361
|
+
else:
|
|
362
|
+
print(f"{prog}: no fallback location was writable; "
|
|
363
|
+
f"the full record follows on stdout",
|
|
364
|
+
file=sys.stderr)
|
|
365
|
+
print(json.dumps(report, ensure_ascii=False,
|
|
366
|
+
indent=2, default=str))
|
|
367
|
+
raise _ReportWriteFailed(str(exc)) from exc
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _run_setup_wizard():
|
|
371
|
+
"""The first-run wizard (test seam): the CLI wires the live
|
|
372
|
+
seat probe; tests substitute this to script a setup."""
|
|
373
|
+
return setup_wizard.run_wizard(
|
|
374
|
+
probe_fn=lambda providers: core.run_doctor(providers))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _evidence_main(argv):
|
|
378
|
+
"""codereview evidence — the Epic C evidence runner,
|
|
379
|
+
standalone: run a config's commands/adapters against a tree,
|
|
380
|
+
or verify the claimed commands in a builder's work report.
|
|
381
|
+
Exit codes mirror the gate: 0 = evidence stands (claims:
|
|
382
|
+
every claim confirmed), 1 = required evidence failed /
|
|
383
|
+
a claim was downgraded, 2 = inconclusive or usage error."""
|
|
384
|
+
ap = argparse.ArgumentParser(
|
|
385
|
+
prog="codereview evidence",
|
|
386
|
+
description="Run configured evidence (tests, lint, SAST, "
|
|
387
|
+
"security adapters) in a sandboxed copy of "
|
|
388
|
+
"the tree, or verify a builder report's "
|
|
389
|
+
"claimed commands by re-running them.")
|
|
390
|
+
ap.add_argument("--repo", default=".",
|
|
391
|
+
help="working tree to run evidence against "
|
|
392
|
+
"(default: .)")
|
|
393
|
+
ap.add_argument("--config",
|
|
394
|
+
help="evidence config JSON (commands + "
|
|
395
|
+
"adapters + policy-required names)")
|
|
396
|
+
ap.add_argument("--claims",
|
|
397
|
+
help="builder work report (markdown/text): "
|
|
398
|
+
"re-run its claimed commands and compare")
|
|
399
|
+
ap.add_argument("--out", default="codereview-out",
|
|
400
|
+
help="output directory for the JSON record "
|
|
401
|
+
"(default: codereview-out)")
|
|
402
|
+
args = ap.parse_args(argv)
|
|
403
|
+
if bool(args.config) == bool(args.claims):
|
|
404
|
+
print("codereview evidence: pass exactly one of --config "
|
|
405
|
+
"or --claims", file=sys.stderr)
|
|
406
|
+
return 2
|
|
407
|
+
os.makedirs(args.out, exist_ok=True)
|
|
408
|
+
if args.claims:
|
|
409
|
+
try:
|
|
410
|
+
with open(os.path.expanduser(args.claims),
|
|
411
|
+
encoding="utf-8", errors="replace") as fh:
|
|
412
|
+
text = fh.read()
|
|
413
|
+
except Exception as exc: # noqa: BLE001
|
|
414
|
+
print(f"codereview evidence: could not read the "
|
|
415
|
+
f"report: {exc}", file=sys.stderr)
|
|
416
|
+
return 2
|
|
417
|
+
result = evidence_mod.verify_claims(text, args.repo)
|
|
418
|
+
path = os.path.join(args.out, "claims.json")
|
|
419
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
420
|
+
json.dump(result, fh, ensure_ascii=False, indent=2)
|
|
421
|
+
fh.write("\n")
|
|
422
|
+
for claim in result["claims"]:
|
|
423
|
+
print(f" {claim['status']:<10} line "
|
|
424
|
+
f"{claim['line']}: {claim['command']}"
|
|
425
|
+
+ (f" — {claim['detail']}"
|
|
426
|
+
if claim.get("detail") else ""))
|
|
427
|
+
summary = result["summary"]
|
|
428
|
+
print(f"codereview evidence: {summary['confirmed']} "
|
|
429
|
+
f"confirmed, {summary['downgraded']} downgraded, "
|
|
430
|
+
f"{summary['error']} error(s) of "
|
|
431
|
+
f"{summary['total']} claim(s)")
|
|
432
|
+
print(f" data: {path}")
|
|
433
|
+
if summary["error"]:
|
|
434
|
+
return 2
|
|
435
|
+
return 1 if summary["downgraded"] else 0
|
|
436
|
+
try:
|
|
437
|
+
config = evidence_mod.load_config(args.config)
|
|
438
|
+
except ValueError as exc:
|
|
439
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
440
|
+
return 2
|
|
441
|
+
try:
|
|
442
|
+
record = evidence_mod.collect(config, args.repo)
|
|
443
|
+
except Exception as exc: # noqa: BLE001
|
|
444
|
+
# A collect failure (e.g. a mistyped config value that
|
|
445
|
+
# passes validation and blows up inside a subprocess
|
|
446
|
+
# call) is a subcommand error — exit 2 with the reason,
|
|
447
|
+
# never a traceback (934fab88a12e).
|
|
448
|
+
print(f"codereview evidence: collection failed: {exc}",
|
|
449
|
+
file=sys.stderr)
|
|
450
|
+
return 2
|
|
451
|
+
path = os.path.join(args.out, "evidence.json")
|
|
452
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
453
|
+
json.dump(record, fh, ensure_ascii=False, indent=2)
|
|
454
|
+
fh.write("\n")
|
|
455
|
+
for item in record["items"]:
|
|
456
|
+
print(f" {item['status']:<11} {item['name']}"
|
|
457
|
+
+ (" (required)" if item["required"] else "")
|
|
458
|
+
+ (f" — {item['detail']}" if item.get("detail")
|
|
459
|
+
else ""))
|
|
460
|
+
effect, reasons = evidence_mod.gate_effect(record["items"])
|
|
461
|
+
print(f"codereview evidence: gate effect = "
|
|
462
|
+
f"{(effect or 'none').upper()}"
|
|
463
|
+
+ (f" ({'; '.join(reasons)})" if reasons else ""))
|
|
464
|
+
print(f" data: {path}")
|
|
465
|
+
return {"blocked": 1, "inconclusive": 2}.get(effect, 0)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _setup_main(argv):
|
|
469
|
+
"""codereview setup — first-run (or re-run) user setup:
|
|
470
|
+
pick providers, name the key env vars, assign per-role
|
|
471
|
+
models, save them as YOUR defaults. No panel is imposed."""
|
|
472
|
+
ap = argparse.ArgumentParser(
|
|
473
|
+
prog="codereview setup",
|
|
474
|
+
description="First-run setup: choose your providers, "
|
|
475
|
+
"keys, and per-role models, and save them "
|
|
476
|
+
"as your defaults (re-runnable to edit).")
|
|
477
|
+
ap.parse_args(argv)
|
|
478
|
+
try:
|
|
479
|
+
result = _run_setup_wizard()
|
|
480
|
+
except KeyboardInterrupt:
|
|
481
|
+
# P5-06: Ctrl-C is a cancellation, not a crash — no
|
|
482
|
+
# traceback. The wizard saves only at the very end, so
|
|
483
|
+
# nothing was written. 130 is the conventional
|
|
484
|
+
# SIGINT exit code.
|
|
485
|
+
print("codereview setup: cancelled — nothing saved.",
|
|
486
|
+
file=sys.stderr)
|
|
487
|
+
return 130
|
|
488
|
+
except EOFError:
|
|
489
|
+
# P5-13: the wizard already printed its abort line
|
|
490
|
+
# ("setup aborted — nothing saved.") at the prompt that
|
|
491
|
+
# hit EOF; re-printing the exception here duplicated
|
|
492
|
+
# the message.
|
|
493
|
+
return 2
|
|
494
|
+
except Exception as exc: # noqa: BLE001
|
|
495
|
+
print(f"codereview setup: {exc}", file=sys.stderr)
|
|
496
|
+
return 2
|
|
497
|
+
return 0 if result else 2
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _loop_main(argv, providers_override=None):
|
|
501
|
+
"""codereview loop — drive review -> builder fix -> verify ->
|
|
502
|
+
advance until PASS or the builder-round budget runs out."""
|
|
503
|
+
ap = argparse.ArgumentParser(
|
|
504
|
+
prog="codereview loop",
|
|
505
|
+
description="Loop driver: reviewers review the working-tree "
|
|
506
|
+
"diff, a builder command (the coding agent) fixes "
|
|
507
|
+
"the findings and writes a work report, and a "
|
|
508
|
+
"verification round decides whether the workflow "
|
|
509
|
+
"advances.",
|
|
510
|
+
epilog="Surface note (P6-08): loop takes --provider (or the "
|
|
511
|
+
"environment / saved setup), --range/--staged, "
|
|
512
|
+
"--waive, --test-command, --allow-major and "
|
|
513
|
+
"--strict — and no --seat-meta, --policy or --role: "
|
|
514
|
+
"it drives its own rounds against the working tree "
|
|
515
|
+
"as the builder edits it, so the policy-driven "
|
|
516
|
+
"surfaces (tiering, hooks, gate profile, role "
|
|
517
|
+
"assignment) belong to the gate command. Its "
|
|
518
|
+
"per-round records carry no committed range/head "
|
|
519
|
+
"and are not Form B chain / release records; for a "
|
|
520
|
+
"release record, run the gate command over a "
|
|
521
|
+
"committed range.")
|
|
522
|
+
ap.add_argument("--repo", default=".",
|
|
523
|
+
help="git repository path (default: .)")
|
|
524
|
+
ap.add_argument("--range", dest="range",
|
|
525
|
+
help="git diff range (default: working tree vs "
|
|
526
|
+
"HEAD)")
|
|
527
|
+
ap.add_argument("--staged", action="store_true")
|
|
528
|
+
ap.add_argument("--builder-cmd", required=True,
|
|
529
|
+
help="command that acts as the coding agent. It "
|
|
530
|
+
"receives $CODEREVIEW_PACKET (JSON: gate + "
|
|
531
|
+
"open findings), edits the working tree, and "
|
|
532
|
+
"writes its work report to $CODEREVIEW_RESPONSE")
|
|
533
|
+
ap.add_argument("--max-rounds", type=int, default=3,
|
|
534
|
+
help="builder rounds before stopping for a human "
|
|
535
|
+
"(default 3)")
|
|
536
|
+
ap.add_argument("--waive", action="append", default=[],
|
|
537
|
+
help="finding ID waived by the owner (recorded "
|
|
538
|
+
"in the result); repeatable")
|
|
539
|
+
ap.add_argument("--provider", action="append", default=[],
|
|
540
|
+
help="provider preset or custom spec; repeatable")
|
|
541
|
+
ap.add_argument("--context", default="")
|
|
542
|
+
ap.add_argument("--test-command", action="append", default=[])
|
|
543
|
+
ap.add_argument("--out", default="codereview-out")
|
|
544
|
+
ap.add_argument("--allow-major", action="store_true")
|
|
545
|
+
ap.add_argument("--strict", action="store_true")
|
|
546
|
+
args = ap.parse_args(argv)
|
|
547
|
+
|
|
548
|
+
if _refuse_unwritable_out(args.out, "codereview loop"):
|
|
549
|
+
return 2
|
|
550
|
+
if providers_override is not None:
|
|
551
|
+
providers = providers_override
|
|
552
|
+
else:
|
|
553
|
+
specs = args.provider
|
|
554
|
+
if not specs:
|
|
555
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
556
|
+
try:
|
|
557
|
+
specs = [s.strip() for s in env.split(",")
|
|
558
|
+
if s.strip()] \
|
|
559
|
+
or userconfig.default_provider_specs()
|
|
560
|
+
except ValueError as exc:
|
|
561
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
562
|
+
return 2
|
|
563
|
+
if not specs:
|
|
564
|
+
print(userconfig.NOT_SET_UP, file=sys.stderr)
|
|
565
|
+
return 2
|
|
566
|
+
try:
|
|
567
|
+
providers = build_providers(specs)
|
|
568
|
+
except Exception as exc: # noqa: BLE001
|
|
569
|
+
print(f"codereview: provider setup failed: {exc}",
|
|
570
|
+
file=sys.stderr)
|
|
571
|
+
return 2
|
|
572
|
+
|
|
573
|
+
os.makedirs(args.out, exist_ok=True)
|
|
574
|
+
packet_path = os.path.join(args.out, "builder-packet.json")
|
|
575
|
+
response_path = os.path.join(args.out, "builder-response.md")
|
|
576
|
+
|
|
577
|
+
def get_patch():
|
|
578
|
+
if not args.staged and not args.range:
|
|
579
|
+
core.include_untracked(args.repo)
|
|
580
|
+
return core.collect_git_patch(args.repo, range_spec=args.range,
|
|
581
|
+
staged=args.staged)
|
|
582
|
+
|
|
583
|
+
def invoke_builder(packet):
|
|
584
|
+
with open(packet_path, "w", encoding="utf-8") as fh:
|
|
585
|
+
json.dump(packet, fh, ensure_ascii=False, indent=1)
|
|
586
|
+
env = dict(os.environ, CODEREVIEW_PACKET=packet_path,
|
|
587
|
+
CODEREVIEW_RESPONSE=response_path,
|
|
588
|
+
CODEREVIEW_GATE=str(packet.get("gate") or ""),
|
|
589
|
+
CODEREVIEW_ROUND=str(packet.get("round") or ""))
|
|
590
|
+
# Legacy aliases (pre-v0.5.0 name) for existing builders.
|
|
591
|
+
for _k in [k for k in env if k.startswith("CODEREVIEW_")]:
|
|
592
|
+
env.setdefault("REVIEWLOOP_" + _k[len("CODEREVIEW_"):],
|
|
593
|
+
env[_k])
|
|
594
|
+
if os.path.exists(response_path):
|
|
595
|
+
os.remove(response_path)
|
|
596
|
+
out = subprocess.run(args.builder_cmd, shell=True, cwd=args.repo,
|
|
597
|
+
env=env, capture_output=True, timeout=3600,
|
|
598
|
+
check=False)
|
|
599
|
+
if os.path.exists(response_path):
|
|
600
|
+
with open(response_path, encoding="utf-8",
|
|
601
|
+
errors="replace") as fh:
|
|
602
|
+
return fh.read()
|
|
603
|
+
return (out.stdout + out.stderr).decode(
|
|
604
|
+
"utf-8", errors="replace")[-4000:]
|
|
605
|
+
|
|
606
|
+
def on_round(rnd, report):
|
|
607
|
+
_write_report(report, os.path.join(args.out,
|
|
608
|
+
f"round-{rnd}"),
|
|
609
|
+
"codereview loop")
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
result = core.run_loop(
|
|
613
|
+
get_patch, invoke_builder, providers,
|
|
614
|
+
context=args.context,
|
|
615
|
+
test_runner=lambda: _run_tests(args.test_command,
|
|
616
|
+
args.repo),
|
|
617
|
+
max_rounds=args.max_rounds,
|
|
618
|
+
allow_major=args.allow_major,
|
|
619
|
+
strict=args.strict, on_round=on_round,
|
|
620
|
+
waived_ids=args.waive)
|
|
621
|
+
except _ReportWriteFailed:
|
|
622
|
+
return 2
|
|
623
|
+
summary = {k: v for k, v in result.items() if k != "rounds"}
|
|
624
|
+
summary["rounds"] = [{"round": r["round"], "kind": r["kind"],
|
|
625
|
+
"gate": r["gate"],
|
|
626
|
+
"files": r.get("files") or [],
|
|
627
|
+
"changes_vs_previous":
|
|
628
|
+
r.get("changes_vs_previous") or {}}
|
|
629
|
+
for r in result["rounds"]]
|
|
630
|
+
summary_path = os.path.join(args.out, "loop-result.json")
|
|
631
|
+
try:
|
|
632
|
+
with open(summary_path, "w", encoding="utf-8") as fh:
|
|
633
|
+
json.dump(summary, fh, ensure_ascii=False, indent=1)
|
|
634
|
+
except OSError as exc:
|
|
635
|
+
# P5-07b: the loop's outcome must survive a late write
|
|
636
|
+
# failure the same way a gate report does — print the
|
|
637
|
+
# final gate, park the summary in a fallback location,
|
|
638
|
+
# exit 2 instead of tracebacking.
|
|
639
|
+
print(f"codereview loop: writing {summary_path} "
|
|
640
|
+
f"failed: {exc}", file=sys.stderr)
|
|
641
|
+
print(f"codereview loop: final gate = "
|
|
642
|
+
f"{result['final_gate'].upper()} — "
|
|
643
|
+
f"{result['stop_reason']}")
|
|
644
|
+
for fallback in (os.getcwd(), tempfile.gettempdir()):
|
|
645
|
+
try:
|
|
646
|
+
alt = os.path.join(fallback, "loop-result.json")
|
|
647
|
+
with open(alt, "w", encoding="utf-8") as fh:
|
|
648
|
+
json.dump(summary, fh, ensure_ascii=False,
|
|
649
|
+
indent=1)
|
|
650
|
+
print(f"codereview loop: summary saved to "
|
|
651
|
+
f"fallback: {alt}")
|
|
652
|
+
break
|
|
653
|
+
except OSError:
|
|
654
|
+
continue
|
|
655
|
+
return 2
|
|
656
|
+
print(f"codereview loop: final gate = "
|
|
657
|
+
f"{result['final_gate'].upper()} — {result['stop_reason']}")
|
|
658
|
+
print(f" rounds: {len(result['rounds'])} "
|
|
659
|
+
f"(details in {args.out}/round-*/ and loop-result.json)")
|
|
660
|
+
return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
|
|
661
|
+
result["final_gate"], 2)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _workflow_main(argv, providers_override=None):
|
|
665
|
+
"""codereview workflow — run a multi-step workflow file; each
|
|
666
|
+
step is gated by the review loop and committed on approval.
|
|
667
|
+
State lives in <out>/workflow-state.json (edit its `waivers`
|
|
668
|
+
map to record owner waivers, then rerun to resume); touching
|
|
669
|
+
<out>/STOP pauses the workflow between steps."""
|
|
670
|
+
ap = argparse.ArgumentParser(prog="codereview workflow")
|
|
671
|
+
ap.add_argument("--file", required=True,
|
|
672
|
+
help="workflow JSON: {name, steps: [{id, title, "
|
|
673
|
+
"agent_cmd, test_commands?}]}")
|
|
674
|
+
ap.add_argument("--repo", default=".")
|
|
675
|
+
ap.add_argument("--out", default="codereview-workflow-out")
|
|
676
|
+
ap.add_argument("--max-rounds", type=int, default=3)
|
|
677
|
+
ap.add_argument("--strict", action="store_true")
|
|
678
|
+
ap.add_argument("--provider", action="append", default=[])
|
|
679
|
+
args = ap.parse_args(argv)
|
|
680
|
+
|
|
681
|
+
if _refuse_unwritable_out(args.out, "codereview workflow"):
|
|
682
|
+
return 2
|
|
683
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
684
|
+
wf = json.load(fh)
|
|
685
|
+
steps = wf.get("steps") or []
|
|
686
|
+
if not steps:
|
|
687
|
+
print("codereview: workflow file has no steps",
|
|
688
|
+
file=sys.stderr)
|
|
689
|
+
return 2
|
|
690
|
+
if providers_override is not None:
|
|
691
|
+
providers = providers_override
|
|
692
|
+
else:
|
|
693
|
+
specs = args.provider
|
|
694
|
+
if not specs:
|
|
695
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
696
|
+
try:
|
|
697
|
+
specs = [s.strip() for s in env.split(",")
|
|
698
|
+
if s.strip()] \
|
|
699
|
+
or userconfig.default_provider_specs()
|
|
700
|
+
except ValueError as exc:
|
|
701
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
702
|
+
return 2
|
|
703
|
+
if not specs:
|
|
704
|
+
print(userconfig.NOT_SET_UP, file=sys.stderr)
|
|
705
|
+
return 2
|
|
706
|
+
try:
|
|
707
|
+
providers = build_providers(specs)
|
|
708
|
+
except Exception as exc: # noqa: BLE001
|
|
709
|
+
print(f"codereview: provider setup failed: {exc}",
|
|
710
|
+
file=sys.stderr)
|
|
711
|
+
return 2
|
|
712
|
+
|
|
713
|
+
os.makedirs(args.out, exist_ok=True)
|
|
714
|
+
state_path = os.path.join(args.out, "workflow-state.json")
|
|
715
|
+
state = {}
|
|
716
|
+
if os.path.exists(state_path):
|
|
717
|
+
with open(state_path, encoding="utf-8") as fh:
|
|
718
|
+
state = json.load(fh)
|
|
719
|
+
|
|
720
|
+
def save_state(s):
|
|
721
|
+
with open(state_path, "w", encoding="utf-8") as fh:
|
|
722
|
+
json.dump(s, fh, ensure_ascii=False, indent=1)
|
|
723
|
+
|
|
724
|
+
result = core.run_workflow(
|
|
725
|
+
steps, providers, args.repo, args.out,
|
|
726
|
+
max_rounds=args.max_rounds, strict=args.strict, state=state,
|
|
727
|
+
save_state=save_state,
|
|
728
|
+
should_stop=lambda: os.path.exists(
|
|
729
|
+
os.path.join(args.out, "STOP")))
|
|
730
|
+
print(f"codereview workflow: {result['workflow_state']}")
|
|
731
|
+
for s in result["steps"]:
|
|
732
|
+
print(f" step {s['id']}: {s['state']}"
|
|
733
|
+
+ (f" (commit {s['commit'][:10]})" if s.get("commit")
|
|
734
|
+
else "")
|
|
735
|
+
+ (f" — {s['error']}" if s.get("error") else ""))
|
|
736
|
+
return {"complete": 0, "stopped": 1}.get(
|
|
737
|
+
result["workflow_state"], 2)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _repo_tree(repo, limit=200):
|
|
741
|
+
"""A shallow file listing for solver context: `git ls-files`
|
|
742
|
+
when the repo is a git checkout, else a depth-limited walk.
|
|
743
|
+
Returns "" when nothing useful can be listed."""
|
|
744
|
+
try:
|
|
745
|
+
if os.path.isdir(os.path.join(repo, ".git")):
|
|
746
|
+
out = subprocess.run(
|
|
747
|
+
["git", "-C", repo, "ls-files"],
|
|
748
|
+
capture_output=True, timeout=30, check=False)
|
|
749
|
+
lines = out.stdout.decode("utf-8", errors="replace") \
|
|
750
|
+
.splitlines()
|
|
751
|
+
return "\n".join(lines[:limit])
|
|
752
|
+
found = []
|
|
753
|
+
for dirpath, dirnames, filenames in os.walk(repo):
|
|
754
|
+
depth = os.path.relpath(dirpath, repo).count(os.sep)
|
|
755
|
+
if depth > 2:
|
|
756
|
+
dirnames[:] = []
|
|
757
|
+
continue
|
|
758
|
+
dirnames[:] = [d for d in dirnames
|
|
759
|
+
if not d.startswith(".")]
|
|
760
|
+
for name in sorted(filenames):
|
|
761
|
+
found.append(os.path.relpath(
|
|
762
|
+
os.path.join(dirpath, name), repo))
|
|
763
|
+
if len(found) >= limit:
|
|
764
|
+
break
|
|
765
|
+
return "\n".join(sorted(found)[:limit])
|
|
766
|
+
except Exception: # noqa: BLE001 - the tree is optional context
|
|
767
|
+
return ""
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _intake_main(argv, solvers_override=None):
|
|
771
|
+
"""codereview intake — issue report in, solution plan out.
|
|
772
|
+
|
|
773
|
+
The solvers are a separate seat type from the reviewers: they
|
|
774
|
+
answer in Markdown (see providers.build_solvers). The primary
|
|
775
|
+
solver's plan lands in <out>/solution.md for the builder."""
|
|
776
|
+
from . import intake
|
|
777
|
+
from .providers import build_solvers
|
|
778
|
+
|
|
779
|
+
ap = argparse.ArgumentParser(
|
|
780
|
+
prog="codereview intake",
|
|
781
|
+
description="Turn an issue report into an executable "
|
|
782
|
+
"solution plan: solver models read the report "
|
|
783
|
+
"(plus optional context files) and write the "
|
|
784
|
+
"plan a builder then implements.")
|
|
785
|
+
ap.add_argument("report", help="issue report (Markdown)")
|
|
786
|
+
ap.add_argument("--context", action="append", default=[],
|
|
787
|
+
help="context file to include (source files, "
|
|
788
|
+
"logs); repeatable")
|
|
789
|
+
ap.add_argument("--repo", default="",
|
|
790
|
+
help="repository the report is about: its file "
|
|
791
|
+
"listing is included as context")
|
|
792
|
+
ap.add_argument("--provider", action="append", default=[],
|
|
793
|
+
help="solver preset or custom spec; repeatable. "
|
|
794
|
+
"The first seat that answers is the primary. "
|
|
795
|
+
"Default: $CODEREVIEW_PROVIDERS or "
|
|
796
|
+
"gemini,nvidia")
|
|
797
|
+
ap.add_argument("--role", action="append", default=[],
|
|
798
|
+
help="assign the solver role's model: "
|
|
799
|
+
"solver=<provider-spec>; the seat becomes "
|
|
800
|
+
"the primary solver, ahead of --provider "
|
|
801
|
+
"seats. Intake assigns the solver role "
|
|
802
|
+
"only")
|
|
803
|
+
ap.add_argument("--timeout", type=int,
|
|
804
|
+
default=intake.DEFAULT_SOLVER_TIMEOUT,
|
|
805
|
+
help="per-solver deadline in seconds "
|
|
806
|
+
"(default 300)")
|
|
807
|
+
ap.add_argument("--out", default="codereview-intake-out")
|
|
808
|
+
args = ap.parse_args(argv)
|
|
809
|
+
|
|
810
|
+
if args.timeout <= 0:
|
|
811
|
+
print("codereview intake: --timeout must be a positive "
|
|
812
|
+
"number of seconds", file=sys.stderr)
|
|
813
|
+
return 2
|
|
814
|
+
if _refuse_unwritable_out(args.out, "codereview intake"):
|
|
815
|
+
return 2
|
|
816
|
+
try:
|
|
817
|
+
with open(os.path.expanduser(args.report), encoding="utf-8",
|
|
818
|
+
errors="replace") as fh:
|
|
819
|
+
report = fh.read()
|
|
820
|
+
except Exception as exc: # noqa: BLE001
|
|
821
|
+
print(f"codereview intake: could not read the report: {exc}",
|
|
822
|
+
file=sys.stderr)
|
|
823
|
+
return 2
|
|
824
|
+
if not report.strip():
|
|
825
|
+
print("codereview intake: the report is empty.",
|
|
826
|
+
file=sys.stderr)
|
|
827
|
+
return 2
|
|
828
|
+
contexts = []
|
|
829
|
+
for path in args.context:
|
|
830
|
+
try:
|
|
831
|
+
with open(os.path.expanduser(path), encoding="utf-8",
|
|
832
|
+
errors="replace") as fh:
|
|
833
|
+
contexts.append((path, fh.read()))
|
|
834
|
+
except Exception as exc: # noqa: BLE001
|
|
835
|
+
print(f"codereview intake: could not read context "
|
|
836
|
+
f"{path}: {exc}", file=sys.stderr)
|
|
837
|
+
return 2
|
|
838
|
+
|
|
839
|
+
if solvers_override is not None:
|
|
840
|
+
solvers = solvers_override
|
|
841
|
+
else:
|
|
842
|
+
specs = args.provider
|
|
843
|
+
if not specs:
|
|
844
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS")
|
|
845
|
+
or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
846
|
+
try:
|
|
847
|
+
specs = [s.strip() for s in env.split(",")
|
|
848
|
+
if s.strip()] \
|
|
849
|
+
or userconfig.default_provider_specs()
|
|
850
|
+
except ValueError as exc:
|
|
851
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
852
|
+
return 2
|
|
853
|
+
if args.role:
|
|
854
|
+
try:
|
|
855
|
+
assignments = registry.parse_role_assignments(
|
|
856
|
+
args.role)
|
|
857
|
+
except ValueError as exc:
|
|
858
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
859
|
+
return 2
|
|
860
|
+
non_solver = sorted(r for r in assignments
|
|
861
|
+
if r != "solver")
|
|
862
|
+
if non_solver:
|
|
863
|
+
print(f"codereview: intake assigns the solver "
|
|
864
|
+
f"role only; got role(s): "
|
|
865
|
+
f"{', '.join(non_solver)}", file=sys.stderr)
|
|
866
|
+
return 2
|
|
867
|
+
if "solver" in assignments:
|
|
868
|
+
specs = [assignments["solver"]] + specs
|
|
869
|
+
if not specs:
|
|
870
|
+
print(userconfig.NOT_SET_UP, file=sys.stderr)
|
|
871
|
+
return 2
|
|
872
|
+
try:
|
|
873
|
+
solvers = build_solvers(specs, agent_timeout=args.timeout)
|
|
874
|
+
except Exception as exc: # noqa: BLE001
|
|
875
|
+
print(f"codereview: solver setup failed: {exc}",
|
|
876
|
+
file=sys.stderr)
|
|
877
|
+
return 2
|
|
878
|
+
|
|
879
|
+
result = intake.run_intake(
|
|
880
|
+
report, solvers, contexts=contexts,
|
|
881
|
+
repo_tree=_repo_tree(args.repo) if args.repo else "",
|
|
882
|
+
timeout=args.timeout)
|
|
883
|
+
solution_path, manifest_path = intake.write_intake(
|
|
884
|
+
result, args.out, report_name=os.path.basename(args.report))
|
|
885
|
+
print(f"codereview intake: {os.path.basename(args.report)}")
|
|
886
|
+
for s in result["solutions"]:
|
|
887
|
+
if s["ok"]:
|
|
888
|
+
print(f" solver {s['name']}: OK "
|
|
889
|
+
f"({s['seconds']}s, {s['chars']} chars)")
|
|
890
|
+
else:
|
|
891
|
+
print(f" solver {s['name']}: FAILED — {s['error']}")
|
|
892
|
+
if result["ok"]:
|
|
893
|
+
print(f" primary: {result['primary']}")
|
|
894
|
+
print(f" solution: {solution_path}")
|
|
895
|
+
print(f" data: {manifest_path}")
|
|
896
|
+
return 0
|
|
897
|
+
print(" no solver produced a plan — see intake.json",
|
|
898
|
+
file=sys.stderr)
|
|
899
|
+
print(f" data: {manifest_path}")
|
|
900
|
+
return 2
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _pr_main(argv, providers_override=None):
|
|
904
|
+
"""codereview pr — review a GitHub pull request where it lives.
|
|
905
|
+
|
|
906
|
+
Read path works without a token on public repos. --post (review
|
|
907
|
+
comment + commit status) needs GITHUB_TOKEN with repo access."""
|
|
908
|
+
from .github import GitHubClient, GitHubError, parse_pr_url, \
|
|
909
|
+
review_pr
|
|
910
|
+
|
|
911
|
+
ap = argparse.ArgumentParser(prog="codereview pr")
|
|
912
|
+
ap.add_argument("--repo", help="OWNER/REPO")
|
|
913
|
+
ap.add_argument("--number", type=int, help="PR number")
|
|
914
|
+
ap.add_argument("--pr", help="full PR URL")
|
|
915
|
+
ap.add_argument("--post", action="store_true",
|
|
916
|
+
help="post the review comment + commit status "
|
|
917
|
+
"(needs GITHUB_TOKEN)")
|
|
918
|
+
ap.add_argument("--request-changes", action="store_true",
|
|
919
|
+
help="use REQUEST_CHANGES when the gate blocks")
|
|
920
|
+
ap.add_argument("--provider", action="append", default=[])
|
|
921
|
+
ap.add_argument("--verify-previous",
|
|
922
|
+
help="previous report JSON: verification round")
|
|
923
|
+
ap.add_argument("--builder-response", default="",
|
|
924
|
+
help="builder's work report (or @file)")
|
|
925
|
+
ap.add_argument("--test-command", action="append", default=[])
|
|
926
|
+
ap.add_argument("--strict", action="store_true")
|
|
927
|
+
ap.add_argument("--allow-major", action="store_true")
|
|
928
|
+
ap.add_argument("--out", default="codereview-pr-out")
|
|
929
|
+
args = ap.parse_args(argv)
|
|
930
|
+
if _refuse_unwritable_out(args.out, "codereview pr"):
|
|
931
|
+
return 2
|
|
932
|
+
try:
|
|
933
|
+
if args.pr:
|
|
934
|
+
owner, repo, number = parse_pr_url(args.pr)
|
|
935
|
+
elif args.repo and args.number:
|
|
936
|
+
owner, repo = args.repo.split("/", 1)
|
|
937
|
+
number = args.number
|
|
938
|
+
else:
|
|
939
|
+
print("codereview pr: give --pr URL or --repo OWNER/REPO "
|
|
940
|
+
"with --number", file=sys.stderr)
|
|
941
|
+
return 2
|
|
942
|
+
except Exception as exc: # noqa: BLE001
|
|
943
|
+
print(f"codereview pr: {exc}", file=sys.stderr)
|
|
944
|
+
return 2
|
|
945
|
+
|
|
946
|
+
if args.post and not os.environ.get("GITHUB_TOKEN", "").strip():
|
|
947
|
+
# P5-02: fail fast — before providers are built, tests
|
|
948
|
+
# run, or any review call is spent. (review_pr re-checks
|
|
949
|
+
# at the library boundary.)
|
|
950
|
+
print("codereview pr: --post needs GITHUB_TOKEN with "
|
|
951
|
+
"repo access", file=sys.stderr)
|
|
952
|
+
return 2
|
|
953
|
+
|
|
954
|
+
builder_response = args.builder_response
|
|
955
|
+
if builder_response.startswith("@"):
|
|
956
|
+
with open(os.path.expanduser(builder_response[1:]),
|
|
957
|
+
encoding="utf-8", errors="replace") as fh:
|
|
958
|
+
builder_response = fh.read()
|
|
959
|
+
previous = None
|
|
960
|
+
if args.verify_previous:
|
|
961
|
+
with open(args.verify_previous, encoding="utf-8") as fh:
|
|
962
|
+
previous = json.load(fh)
|
|
963
|
+
|
|
964
|
+
if providers_override is not None:
|
|
965
|
+
providers = providers_override
|
|
966
|
+
else:
|
|
967
|
+
specs = args.provider
|
|
968
|
+
if not specs:
|
|
969
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
970
|
+
try:
|
|
971
|
+
specs = [s.strip() for s in env.split(",")
|
|
972
|
+
if s.strip()] \
|
|
973
|
+
or userconfig.default_provider_specs()
|
|
974
|
+
except ValueError as exc:
|
|
975
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
976
|
+
return 2
|
|
977
|
+
if not specs:
|
|
978
|
+
print(userconfig.NOT_SET_UP, file=sys.stderr)
|
|
979
|
+
return 2
|
|
980
|
+
try:
|
|
981
|
+
providers = build_providers(specs)
|
|
982
|
+
except Exception as exc: # noqa: BLE001
|
|
983
|
+
print(f"codereview: provider setup failed: {exc}",
|
|
984
|
+
file=sys.stderr)
|
|
985
|
+
return 2
|
|
986
|
+
|
|
987
|
+
test_results = _run_tests(args.test_command, ".")
|
|
988
|
+
client = GitHubClient()
|
|
989
|
+
if args.post:
|
|
990
|
+
# L17: a run that crashes after reading the PR must not
|
|
991
|
+
# leave a stale or missing verdict looking like success —
|
|
992
|
+
# mark the head commit pending (best effort, never success).
|
|
993
|
+
def _mark_crashed_pending():
|
|
994
|
+
sha = getattr(client, "head_sha", None)
|
|
995
|
+
if sha and not getattr(client, "status_set", False):
|
|
996
|
+
client.set_status(
|
|
997
|
+
owner, repo, sha, "pending",
|
|
998
|
+
"codereview crashed - no verdict was reached")
|
|
999
|
+
crash_mod.register_crash_hook(_mark_crashed_pending)
|
|
1000
|
+
try:
|
|
1001
|
+
report, actions = review_pr(
|
|
1002
|
+
client, owner, repo, number, providers,
|
|
1003
|
+
post=args.post, request_changes=args.request_changes,
|
|
1004
|
+
previous=previous, builder_response=builder_response,
|
|
1005
|
+
test_results=test_results, strict=args.strict,
|
|
1006
|
+
allow_major=args.allow_major)
|
|
1007
|
+
except GitHubError as exc:
|
|
1008
|
+
print(f"codereview pr: {exc}", file=sys.stderr)
|
|
1009
|
+
return 2
|
|
1010
|
+
try:
|
|
1011
|
+
jpath, mpath = _write_report(report, args.out,
|
|
1012
|
+
"codereview pr")
|
|
1013
|
+
except _ReportWriteFailed:
|
|
1014
|
+
return 2
|
|
1015
|
+
print(f"codereview pr: {owner}/{repo}#{number} — "
|
|
1016
|
+
f"{report['pr']['title']}")
|
|
1017
|
+
print(f" gate = {report['gate'].upper()} "
|
|
1018
|
+
f"({'; '.join(report.get('gate_reasons') or [])})")
|
|
1019
|
+
for a in actions:
|
|
1020
|
+
print(f" posted: {a}")
|
|
1021
|
+
print(f" report: {mpath}")
|
|
1022
|
+
print(f" data: {jpath}")
|
|
1023
|
+
posting = report.get("posting") or {}
|
|
1024
|
+
if posting.get("error"):
|
|
1025
|
+
# P5-03: the report above is written and names what was
|
|
1026
|
+
# already posted; the exit code still reflects the
|
|
1027
|
+
# posting failure.
|
|
1028
|
+
print(f"codereview pr: posting failed: {posting['error']}",
|
|
1029
|
+
file=sys.stderr)
|
|
1030
|
+
return 2
|
|
1031
|
+
return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
|
|
1032
|
+
report["gate"], 2)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
_FAMILY_BY_PRESET = {"gemini": "google", "openai": "openai",
|
|
1036
|
+
"xai": "xai", "nvidia": "nvidia",
|
|
1037
|
+
"deepseek": "deepseek",
|
|
1038
|
+
"anthropic": "anthropic",
|
|
1039
|
+
"minimax": "minimax", "moonshot": "moonshot",
|
|
1040
|
+
"mistral": "mistral",
|
|
1041
|
+
"openrouter": "openrouter"}
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _apply_role_assignments(providers, role_values,
|
|
1045
|
+
agent_timeout=None, herdr_host=None,
|
|
1046
|
+
herdr_seats=None):
|
|
1047
|
+
"""Resolve --role <role>=<provider-spec> assignments against
|
|
1048
|
+
the panel: each role's seat is built (once) and merged into
|
|
1049
|
+
`providers` if not already present, so the role's model is
|
|
1050
|
+
guaranteed a seat. Returns (lens_seats, seat_roles,
|
|
1051
|
+
role_models): lens pins for the two review lenses, the
|
|
1052
|
+
role -> seat-name map for the record, and the role ->
|
|
1053
|
+
{seat, provider, model, family} detail the record carries so
|
|
1054
|
+
anyone can see which model served which role. Raises
|
|
1055
|
+
ValueError/ProviderError on a bad assignment — callers turn
|
|
1056
|
+
that into exit 2, never a silent default."""
|
|
1057
|
+
assignments = registry.parse_role_assignments(role_values)
|
|
1058
|
+
lens_seats, seat_roles, role_models = {}, {}, {}
|
|
1059
|
+
for role, spec in assignments.items():
|
|
1060
|
+
desc = describe_spec(spec)
|
|
1061
|
+
record_model = desc["model"]
|
|
1062
|
+
record_family = desc["family"]
|
|
1063
|
+
requested_model = None
|
|
1064
|
+
built = build_providers([spec],
|
|
1065
|
+
agent_timeout=agent_timeout,
|
|
1066
|
+
herdr_host=herdr_host,
|
|
1067
|
+
herdr_seats=herdr_seats)
|
|
1068
|
+
(seat_name, seat_fn), = built.items()
|
|
1069
|
+
if seat_name in providers:
|
|
1070
|
+
# Cross-kind collision (internal review, refutation-
|
|
1071
|
+
# weak finding on this seam): an agent role seat whose
|
|
1072
|
+
# name an API seat already owns (or the reverse) must
|
|
1073
|
+
# not silently record the role as served by the other
|
|
1074
|
+
# kind of seat. When the existing seat carries kind/
|
|
1075
|
+
# family tags that prove the mismatch, refuse loudly —
|
|
1076
|
+
# the same rule build_providers enforces inside one
|
|
1077
|
+
# spec list. Untagged override seats can't prove a
|
|
1078
|
+
# mismatch and keep the historical merge.
|
|
1079
|
+
existing = providers[seat_name]
|
|
1080
|
+
existing_tags = (
|
|
1081
|
+
getattr(existing, "_codereview_kind", None),
|
|
1082
|
+
getattr(existing, "_codereview_family", None))
|
|
1083
|
+
existing_is_agent = "agent" in existing_tags
|
|
1084
|
+
if (desc["kind"] == "agent") != existing_is_agent \
|
|
1085
|
+
and any(t is not None for t in existing_tags):
|
|
1086
|
+
raise ProviderError(
|
|
1087
|
+
f"role {role!r}: seat name {seat_name!r} is "
|
|
1088
|
+
f"already used by a different kind of seat "
|
|
1089
|
+
f"(agent vs API); rename the seat in the "
|
|
1090
|
+
f"role spec {spec!r}")
|
|
1091
|
+
# Record fidelity (ratified fix, 78ddf4d5ea27): the
|
|
1092
|
+
# seat built from the role spec is DISCARDED here —
|
|
1093
|
+
# the role is served by the EXISTING seat, whose model
|
|
1094
|
+
# may differ from the role spec's (a pooled preset
|
|
1095
|
+
# seat serves its own default, not a same-name
|
|
1096
|
+
# override). Crediting the spec's model would record
|
|
1097
|
+
# a model that never serves. When the existing seat's
|
|
1098
|
+
# served model(s) are known (tagged at build time),
|
|
1099
|
+
# the record carries the SERVED model and preserves
|
|
1100
|
+
# the requested one alongside; a seat serving several
|
|
1101
|
+
# distinct models cannot honestly be credited with
|
|
1102
|
+
# any single one, so the assignment is refused.
|
|
1103
|
+
served_models = getattr(existing,
|
|
1104
|
+
"_codereview_models", None)
|
|
1105
|
+
if desc["model"] is not None and served_models:
|
|
1106
|
+
if len(served_models) > 1:
|
|
1107
|
+
raise ProviderError(
|
|
1108
|
+
f"role {role!r}: seat {seat_name!r} "
|
|
1109
|
+
f"already exists and its accounts serve "
|
|
1110
|
+
f"different models "
|
|
1111
|
+
f"{list(served_models)}; the role cannot "
|
|
1112
|
+
f"be credited to one model — give the "
|
|
1113
|
+
f"role its own seat via a distinct spec")
|
|
1114
|
+
if desc["model"] != served_models[0]:
|
|
1115
|
+
requested_model = desc["model"]
|
|
1116
|
+
record_model = served_models[0]
|
|
1117
|
+
existing_family = getattr(
|
|
1118
|
+
existing, "_codereview_family", None)
|
|
1119
|
+
if existing_family:
|
|
1120
|
+
record_family = existing_family
|
|
1121
|
+
else:
|
|
1122
|
+
providers[seat_name] = seat_fn
|
|
1123
|
+
seat_roles[role] = seat_name
|
|
1124
|
+
role_models[role] = {
|
|
1125
|
+
"seat": seat_name,
|
|
1126
|
+
"provider": desc["preset"] or desc["kind"],
|
|
1127
|
+
"model": record_model,
|
|
1128
|
+
"family": record_family,
|
|
1129
|
+
"key_env": desc["key_env"],
|
|
1130
|
+
}
|
|
1131
|
+
if requested_model is not None:
|
|
1132
|
+
role_models[role]["requested_model"] = requested_model
|
|
1133
|
+
if role in registry.LENS_ROLES:
|
|
1134
|
+
lens_seats[registry.LENS_ROLES[role]] = seat_name
|
|
1135
|
+
tech_lead = seat_roles.get("tech-lead")
|
|
1136
|
+
return lens_seats, seat_roles, role_models, tech_lead
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _default_seat_meta(providers):
|
|
1140
|
+
"""Baseline family tags from seat names (v0.7.3): a preset seat
|
|
1141
|
+
carries its vendor family; an agent seat carries the vendor
|
|
1142
|
+
family of the CLI it runs when that CLI is known (P6-06:
|
|
1143
|
+
claude -> anthropic, codex -> openai, gemini -> google,
|
|
1144
|
+
derived by build_providers from the seat's spec), else the
|
|
1145
|
+
generic family 'agent'. Tier stays 'unknown' unless tagged
|
|
1146
|
+
— recorded, never guessed.
|
|
1147
|
+
|
|
1148
|
+
Agent seats are detected by the kind tag build_providers puts
|
|
1149
|
+
on the callable, NOT by name shape: resolved agent seat names
|
|
1150
|
+
are bare (bot, bot-2, ...) and can collide with preset names,
|
|
1151
|
+
so name-prefix guessing both missed suffixed agent seats and
|
|
1152
|
+
could tag an agent named like a preset with the preset's
|
|
1153
|
+
vendor family (review-v11 finding)."""
|
|
1154
|
+
meta = {}
|
|
1155
|
+
for name, fn in providers.items():
|
|
1156
|
+
if getattr(fn, "_codereview_kind", None) == "agent" \
|
|
1157
|
+
or name.startswith("agent:"):
|
|
1158
|
+
tagged = getattr(fn, "_codereview_family", None)
|
|
1159
|
+
meta[name] = {"family": tagged or "agent",
|
|
1160
|
+
"tier": "unknown"}
|
|
1161
|
+
continue
|
|
1162
|
+
tagged = getattr(fn, "_codereview_family", None)
|
|
1163
|
+
if tagged and tagged != "unknown":
|
|
1164
|
+
# The registry family the seat was BUILT from wins
|
|
1165
|
+
# over name guessing: a seat named 'kimi' from the
|
|
1166
|
+
# moonshot preset is family 'moonshot'.
|
|
1167
|
+
meta[name] = {"family": tagged, "tier": "unknown"}
|
|
1168
|
+
continue
|
|
1169
|
+
base = name.split("-")[0].lower() if name else ""
|
|
1170
|
+
family = _FAMILY_BY_PRESET.get(base)
|
|
1171
|
+
meta[name] = {"family": family or "unknown",
|
|
1172
|
+
"tier": "unknown"}
|
|
1173
|
+
return meta
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _parse_seat_meta(values, base=None):
|
|
1177
|
+
"""--seat-meta name:family[:tier] entries over a baseline."""
|
|
1178
|
+
meta = dict(base or {})
|
|
1179
|
+
for value in values or []:
|
|
1180
|
+
parts = value.split(":")
|
|
1181
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
1182
|
+
raise ValueError(
|
|
1183
|
+
f"bad --seat-meta {value!r}: want name:family[:tier]")
|
|
1184
|
+
entry = dict(meta.get(parts[0]) or {})
|
|
1185
|
+
entry["family"] = parts[1]
|
|
1186
|
+
if len(parts) > 2 and parts[2]:
|
|
1187
|
+
entry["tier"] = parts[2]
|
|
1188
|
+
entry.setdefault("tier", "unknown")
|
|
1189
|
+
meta[parts[0]] = entry
|
|
1190
|
+
return meta
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _doctor_main(argv, providers_override=None,
|
|
1194
|
+
fallbacks_override=None):
|
|
1195
|
+
ap = argparse.ArgumentParser(
|
|
1196
|
+
prog="codereview doctor",
|
|
1197
|
+
description="Preflight: probe every configured seat — "
|
|
1198
|
+
"primaries AND fallbacks — with the exact "
|
|
1199
|
+
"review request shape and report health as "
|
|
1200
|
+
"JSON before any review runs.")
|
|
1201
|
+
ap.add_argument("--provider", action="append", default=[],
|
|
1202
|
+
help="provider spec (same syntax as review); "
|
|
1203
|
+
"repeatable. Default: $CODEREVIEW_PROVIDERS "
|
|
1204
|
+
"or your saved setup (codereview setup)")
|
|
1205
|
+
ap.add_argument("--fallback", action="append", default=[],
|
|
1206
|
+
help="fallback seat to probe too: "
|
|
1207
|
+
"lens=provider-spec (same syntax as "
|
|
1208
|
+
"review); repeatable")
|
|
1209
|
+
ap.add_argument("--role", action="append", default=[],
|
|
1210
|
+
help="role seat to probe too: "
|
|
1211
|
+
"<role>=<provider-spec> (same roles as "
|
|
1212
|
+
"review); repeatable")
|
|
1213
|
+
ap.add_argument("--timeout", type=int, default=None,
|
|
1214
|
+
help="per-seat timeout in seconds for agent "
|
|
1215
|
+
"seats (forwarded to provider "
|
|
1216
|
+
"construction, like review/intake); "
|
|
1217
|
+
"also the probe's per-seat wait bound "
|
|
1218
|
+
"(default: the review path's per-call "
|
|
1219
|
+
"ceiling)")
|
|
1220
|
+
args = ap.parse_args(argv)
|
|
1221
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
1222
|
+
print(json.dumps(
|
|
1223
|
+
{"ok": False, "seats": [],
|
|
1224
|
+
"error": "--timeout must be a positive number "
|
|
1225
|
+
"of seconds"}))
|
|
1226
|
+
return 2
|
|
1227
|
+
if providers_override is not None:
|
|
1228
|
+
providers = providers_override
|
|
1229
|
+
else:
|
|
1230
|
+
specs = args.provider
|
|
1231
|
+
if not specs:
|
|
1232
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS")
|
|
1233
|
+
or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
1234
|
+
try:
|
|
1235
|
+
specs = [s.strip() for s in env.split(",")
|
|
1236
|
+
if s.strip()] \
|
|
1237
|
+
or userconfig.default_provider_specs()
|
|
1238
|
+
except ValueError as exc:
|
|
1239
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
1240
|
+
return 2
|
|
1241
|
+
if not specs:
|
|
1242
|
+
print(json.dumps(
|
|
1243
|
+
{"ok": False, "seats": [],
|
|
1244
|
+
"error": "not set up: pass --provider, set "
|
|
1245
|
+
"$CODEREVIEW_PROVIDERS, or run "
|
|
1246
|
+
"`codereview setup`"}))
|
|
1247
|
+
return 2
|
|
1248
|
+
try:
|
|
1249
|
+
providers = build_providers(
|
|
1250
|
+
specs, agent_timeout=args.timeout)
|
|
1251
|
+
except Exception as exc: # noqa: BLE001
|
|
1252
|
+
print(json.dumps({"ok": False, "seats": [],
|
|
1253
|
+
"error": f"provider setup failed: "
|
|
1254
|
+
f"{exc}"}))
|
|
1255
|
+
return 2
|
|
1256
|
+
fallbacks = fallbacks_override
|
|
1257
|
+
if fallbacks is None and args.fallback:
|
|
1258
|
+
fallbacks = {}
|
|
1259
|
+
for spec in args.fallback:
|
|
1260
|
+
lens, eq, pspec = spec.partition("=")
|
|
1261
|
+
lens = lens.strip()
|
|
1262
|
+
if not eq or lens not in core.LENSES \
|
|
1263
|
+
or not pspec.strip():
|
|
1264
|
+
print(json.dumps(
|
|
1265
|
+
{"ok": False, "seats": [],
|
|
1266
|
+
"error": f"bad --fallback {spec!r}: want "
|
|
1267
|
+
f"lens=provider-spec with lens one "
|
|
1268
|
+
f"of {sorted(core.LENSES)}"}))
|
|
1269
|
+
return 2
|
|
1270
|
+
try:
|
|
1271
|
+
built = build_providers(
|
|
1272
|
+
[pspec.strip()], agent_timeout=args.timeout)
|
|
1273
|
+
except Exception as exc: # noqa: BLE001
|
|
1274
|
+
print(json.dumps(
|
|
1275
|
+
{"ok": False, "seats": [],
|
|
1276
|
+
"error": f"fallback setup failed: {exc}"}))
|
|
1277
|
+
return 2
|
|
1278
|
+
(fb_name, fb_fn), = built.items()
|
|
1279
|
+
if fb_name in providers:
|
|
1280
|
+
# Same guard as the review path (764e1d15a218):
|
|
1281
|
+
# a fallback that duplicates a primary seat is
|
|
1282
|
+
# a configuration error, not a second opinion.
|
|
1283
|
+
print(json.dumps(
|
|
1284
|
+
{"ok": False, "seats": [],
|
|
1285
|
+
"error": f"fallback seat {fb_name!r} is "
|
|
1286
|
+
f"already a primary seat"}))
|
|
1287
|
+
return 2
|
|
1288
|
+
fallbacks[lens] = (fb_name, fb_fn)
|
|
1289
|
+
seat_roles, role_models = {}, {}
|
|
1290
|
+
if args.role:
|
|
1291
|
+
try:
|
|
1292
|
+
_lens, seat_roles, role_models, _tl = \
|
|
1293
|
+
_apply_role_assignments(
|
|
1294
|
+
providers, args.role,
|
|
1295
|
+
agent_timeout=args.timeout)
|
|
1296
|
+
except Exception as exc: # noqa: BLE001
|
|
1297
|
+
print(json.dumps(
|
|
1298
|
+
{"ok": False, "seats": [],
|
|
1299
|
+
"error": f"role assignment failed: {exc}"}))
|
|
1300
|
+
return 2
|
|
1301
|
+
result = core.run_doctor(providers, fallbacks=fallbacks,
|
|
1302
|
+
timeout=args.timeout)
|
|
1303
|
+
if seat_roles:
|
|
1304
|
+
result = {**result, "seat_roles": seat_roles,
|
|
1305
|
+
"role_models": role_models}
|
|
1306
|
+
print(json.dumps(result, ensure_ascii=False, indent=1))
|
|
1307
|
+
return 0 if result["ok"] else 2
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _timestamped_record_name(name):
|
|
1311
|
+
"""True for write_report's record stem
|
|
1312
|
+
(codereview-YYYYMMDD-HHMMSS...): the filename itself carries
|
|
1313
|
+
the run timestamp, so name order is chronological."""
|
|
1314
|
+
if not name.startswith("codereview-"):
|
|
1315
|
+
return False
|
|
1316
|
+
stamp = name[len("codereview-"):]
|
|
1317
|
+
return (len(stamp) >= 15 and stamp[8] == "-"
|
|
1318
|
+
and stamp[:8].isdigit() and stamp[9:15].isdigit())
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _load_record_arg(path, repo):
|
|
1322
|
+
"""Resolve a record argument (directory or JSON file) to
|
|
1323
|
+
(record, record_dir, jpath). record_dir is repo-relative —
|
|
1324
|
+
the head-currency checks allow later commits that touch only
|
|
1325
|
+
the record directories."""
|
|
1326
|
+
if os.path.isdir(path):
|
|
1327
|
+
candidates = [os.path.join(path, f)
|
|
1328
|
+
for f in os.listdir(path)
|
|
1329
|
+
if f.endswith(".json")]
|
|
1330
|
+
if not candidates:
|
|
1331
|
+
raise ValueError(f"no record JSON in {path}")
|
|
1332
|
+
stamped = [p for p in candidates
|
|
1333
|
+
if _timestamped_record_name(
|
|
1334
|
+
os.path.basename(p))]
|
|
1335
|
+
if stamped:
|
|
1336
|
+
# Prefer the lexicographically greatest timestamped
|
|
1337
|
+
# record name: name order is chronological AND
|
|
1338
|
+
# copy-stable, while mtime order breaks on copied or
|
|
1339
|
+
# re-exported record trees — and an auxiliary JSON
|
|
1340
|
+
# sharing the directory could otherwise outrank the
|
|
1341
|
+
# actual record (179ece0e030e). mtime remains the
|
|
1342
|
+
# fallback when no candidate carries a timestamp.
|
|
1343
|
+
jpath = max(stamped)
|
|
1344
|
+
else:
|
|
1345
|
+
jpath = max(candidates, key=os.path.getmtime)
|
|
1346
|
+
record_dir = os.path.relpath(
|
|
1347
|
+
os.path.abspath(path),
|
|
1348
|
+
os.path.abspath(repo)).replace(os.sep, "/")
|
|
1349
|
+
else:
|
|
1350
|
+
jpath = path
|
|
1351
|
+
record_dir = os.path.relpath(
|
|
1352
|
+
os.path.abspath(os.path.dirname(path) or "."),
|
|
1353
|
+
os.path.abspath(repo)).replace(os.sep, "/")
|
|
1354
|
+
with open(jpath, encoding="utf-8") as fh:
|
|
1355
|
+
record = json.load(fh)
|
|
1356
|
+
return record, record_dir, jpath
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _print_verify_result(result, label):
|
|
1360
|
+
for check in result["checks"]:
|
|
1361
|
+
mark = "ok " if check["ok"] else "FAIL"
|
|
1362
|
+
print(f" [{mark}] {check['name']}: {check['detail']}")
|
|
1363
|
+
for warning in result.get("warnings") or []:
|
|
1364
|
+
print(f" [WARN] {warning['name']}: {warning['detail']}")
|
|
1365
|
+
for entry in result.get("dispositions_consumed") or []:
|
|
1366
|
+
print(f" [store] disposition consumed: "
|
|
1367
|
+
f"{entry.get('finding_id')}: {entry.get('type')} "
|
|
1368
|
+
f"by {entry.get('decider')} ({entry.get('date')})")
|
|
1369
|
+
print(f"codereview: {label} "
|
|
1370
|
+
f"{'ELIGIBLE' if result['eligible'] else 'NOT eligible'}")
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _record_verify_main(argv):
|
|
1374
|
+
ap = argparse.ArgumentParser(
|
|
1375
|
+
prog="codereview record-verify",
|
|
1376
|
+
description="Check whether a saved review record is "
|
|
1377
|
+
"eligible as a release record (clean gate, "
|
|
1378
|
+
"full panel, current range).")
|
|
1379
|
+
ap.add_argument("path", nargs="?",
|
|
1380
|
+
help="record directory or record JSON file")
|
|
1381
|
+
ap.add_argument("--repo", default=".",
|
|
1382
|
+
help="repository the record belongs to")
|
|
1383
|
+
ap.add_argument("--chain", nargs="+", metavar="RECORD",
|
|
1384
|
+
help="Form B: verify an ordered chain of "
|
|
1385
|
+
"records (round-1 review followed by "
|
|
1386
|
+
"verification rounds) as one "
|
|
1387
|
+
"round-structured record. Convergence-"
|
|
1388
|
+
"budget breaches are reported as WARN "
|
|
1389
|
+
"lines — process flags for the owner — "
|
|
1390
|
+
"and do not by themselves change the "
|
|
1391
|
+
"exit code; eligibility is decided by "
|
|
1392
|
+
"the checks alone.")
|
|
1393
|
+
ap.add_argument("--dispositions",
|
|
1394
|
+
help="disposition store (JSONL) to consult "
|
|
1395
|
+
"(§5.2): disputed findings with a store "
|
|
1396
|
+
"entry count as dispositioned, and the "
|
|
1397
|
+
"consumed entries are reported. Default: "
|
|
1398
|
+
"the store at <repo>/dispositions.jsonl "
|
|
1399
|
+
"when one exists; without any store, "
|
|
1400
|
+
"verification behaves exactly as before.")
|
|
1401
|
+
args = ap.parse_args(argv)
|
|
1402
|
+
dispositions = None
|
|
1403
|
+
store_path = args.dispositions or disputes_mod.store_path_for(
|
|
1404
|
+
args.repo)
|
|
1405
|
+
if args.dispositions or os.path.isfile(store_path):
|
|
1406
|
+
loaded = disputes_mod.load_store(store_path)
|
|
1407
|
+
if loaded["problems"]:
|
|
1408
|
+
print("codereview: the disposition store has "
|
|
1409
|
+
"problems; refusing to verify against it:",
|
|
1410
|
+
file=sys.stderr)
|
|
1411
|
+
for problem in loaded["problems"]:
|
|
1412
|
+
print(f" {problem}", file=sys.stderr)
|
|
1413
|
+
return 2
|
|
1414
|
+
dispositions = loaded["entries"]
|
|
1415
|
+
print(f"codereview: disposition store: "
|
|
1416
|
+
f"{len(dispositions)} entr(ies) from {store_path}",
|
|
1417
|
+
file=sys.stderr)
|
|
1418
|
+
try:
|
|
1419
|
+
if args.chain:
|
|
1420
|
+
records, dirs = [], []
|
|
1421
|
+
for p in args.chain:
|
|
1422
|
+
record, record_dir, _jp = _load_record_arg(
|
|
1423
|
+
p, args.repo)
|
|
1424
|
+
records.append(record)
|
|
1425
|
+
dirs.append(record_dir)
|
|
1426
|
+
result = core.verify_chain(
|
|
1427
|
+
records, repo=args.repo, record_dirs=dirs,
|
|
1428
|
+
dispositions=dispositions)
|
|
1429
|
+
_print_verify_result(
|
|
1430
|
+
result,
|
|
1431
|
+
f"chain of {len(records)} record(s)")
|
|
1432
|
+
return 0 if result["eligible"] else 1
|
|
1433
|
+
if not args.path:
|
|
1434
|
+
ap.error("a record path is required (or --chain)")
|
|
1435
|
+
record, record_dir, jpath = _load_record_arg(
|
|
1436
|
+
args.path, args.repo)
|
|
1437
|
+
except Exception as exc: # noqa: BLE001
|
|
1438
|
+
print(f"codereview: could not read the record: {exc}",
|
|
1439
|
+
file=sys.stderr)
|
|
1440
|
+
return 2
|
|
1441
|
+
result = core.verify_record(record, repo=args.repo,
|
|
1442
|
+
record_dir=record_dir,
|
|
1443
|
+
dispositions=dispositions)
|
|
1444
|
+
_print_verify_result(result, f"record ({jpath})")
|
|
1445
|
+
return 0 if result["eligible"] else 1
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _disputes_main(argv):
|
|
1449
|
+
"""codereview disputes — the computed open-dispute
|
|
1450
|
+
inventory (§5.1), the disposition-store append (§5.2), and
|
|
1451
|
+
the release-flow freshness check (§5.3)."""
|
|
1452
|
+
ap = argparse.ArgumentParser(
|
|
1453
|
+
prog="codereview disputes",
|
|
1454
|
+
description="Compute the open-dispute inventory from "
|
|
1455
|
+
"the review records under a repo: findings "
|
|
1456
|
+
"in a human-decision state (disputed or "
|
|
1457
|
+
"refutation-weak) with no disposition in "
|
|
1458
|
+
"the store. With --record, append one "
|
|
1459
|
+
"disposition entry instead. With --check, "
|
|
1460
|
+
"act as the release-flow freshness gate.")
|
|
1461
|
+
ap.add_argument("--repo", default=".",
|
|
1462
|
+
help="repository whose review-*/ records "
|
|
1463
|
+
"and disposition store are read "
|
|
1464
|
+
"(default: .)")
|
|
1465
|
+
ap.add_argument("--store",
|
|
1466
|
+
help="disposition store path (default: "
|
|
1467
|
+
"<repo>/dispositions.jsonl)")
|
|
1468
|
+
ap.add_argument("--json", action="store_true",
|
|
1469
|
+
help="print the inventory as JSON")
|
|
1470
|
+
ap.add_argument("--record", metavar="FILE",
|
|
1471
|
+
help="append the disposition entry in FILE "
|
|
1472
|
+
"(JSON object; '-' reads stdin) to the "
|
|
1473
|
+
"store, with validation — a fingerprint "
|
|
1474
|
+
"may be dispositioned once; a second "
|
|
1475
|
+
"entry must be an override naming the "
|
|
1476
|
+
"entry it supersedes")
|
|
1477
|
+
ap.add_argument("--check", action="store_true",
|
|
1478
|
+
help="freshness gate: exit 1 listing every "
|
|
1479
|
+
"open dispute in the checked records "
|
|
1480
|
+
"that is neither dispositioned nor "
|
|
1481
|
+
"named in the --inventory file")
|
|
1482
|
+
ap.add_argument("--span", metavar="A..B",
|
|
1483
|
+
help="with --check: only records touching "
|
|
1484
|
+
"this commit span count (default: all "
|
|
1485
|
+
"records under --repo)")
|
|
1486
|
+
ap.add_argument("--inventory", metavar="FILE",
|
|
1487
|
+
help="with --check: a caller-supplied "
|
|
1488
|
+
"inventory (JSON) whose finding ids "
|
|
1489
|
+
"are excused from the gate")
|
|
1490
|
+
ap.add_argument("--overrides", metavar="FILE",
|
|
1491
|
+
help="tagger manual overrides (JSON): "
|
|
1492
|
+
"{\"authority\": [ids], "
|
|
1493
|
+
"\"not_authority\": [ids]}")
|
|
1494
|
+
args = ap.parse_args(argv)
|
|
1495
|
+
store_path = args.store or disputes_mod.store_path_for(
|
|
1496
|
+
args.repo)
|
|
1497
|
+
loaded = disputes_mod.load_store(store_path)
|
|
1498
|
+
if loaded["problems"]:
|
|
1499
|
+
print("codereview: the disposition store has problems:",
|
|
1500
|
+
file=sys.stderr)
|
|
1501
|
+
for problem in loaded["problems"]:
|
|
1502
|
+
print(f" {problem}", file=sys.stderr)
|
|
1503
|
+
return 2
|
|
1504
|
+
entries = loaded["entries"]
|
|
1505
|
+
|
|
1506
|
+
if args.record:
|
|
1507
|
+
try:
|
|
1508
|
+
raw = sys.stdin.read() if args.record == "-" else \
|
|
1509
|
+
open(args.record, encoding="utf-8").read()
|
|
1510
|
+
entry = json.loads(raw)
|
|
1511
|
+
except Exception as exc: # noqa: BLE001
|
|
1512
|
+
print(f"codereview: could not read the entry: {exc}",
|
|
1513
|
+
file=sys.stderr)
|
|
1514
|
+
return 2
|
|
1515
|
+
try:
|
|
1516
|
+
stored = disputes_mod.append_entry(store_path, entry)
|
|
1517
|
+
except ValueError as exc:
|
|
1518
|
+
print(f"codereview: entry rejected: {exc}",
|
|
1519
|
+
file=sys.stderr)
|
|
1520
|
+
return 2
|
|
1521
|
+
print(f"codereview: disposition recorded: "
|
|
1522
|
+
f"{stored['finding_id']}: {stored['type']} by "
|
|
1523
|
+
f"{stored['decider']} ({stored['date']}), "
|
|
1524
|
+
f"entry sha256 "
|
|
1525
|
+
f"{disputes_mod.entry_hash(stored)[:12]}…")
|
|
1526
|
+
return 0
|
|
1527
|
+
|
|
1528
|
+
overrides = None
|
|
1529
|
+
if args.overrides:
|
|
1530
|
+
try:
|
|
1531
|
+
with open(args.overrides, encoding="utf-8") as fh:
|
|
1532
|
+
overrides = json.load(fh)
|
|
1533
|
+
except Exception as exc: # noqa: BLE001
|
|
1534
|
+
print(f"codereview: could not read the overrides: "
|
|
1535
|
+
f"{exc}", file=sys.stderr)
|
|
1536
|
+
return 2
|
|
1537
|
+
|
|
1538
|
+
if args.check:
|
|
1539
|
+
inventory_ids = set()
|
|
1540
|
+
if args.inventory:
|
|
1541
|
+
try:
|
|
1542
|
+
inventory_ids = \
|
|
1543
|
+
disputes_mod.inventory_ids_from_file(
|
|
1544
|
+
args.inventory)
|
|
1545
|
+
except Exception as exc: # noqa: BLE001
|
|
1546
|
+
print(f"codereview: could not read the "
|
|
1547
|
+
f"inventory file: {exc}", file=sys.stderr)
|
|
1548
|
+
return 2
|
|
1549
|
+
if args.span:
|
|
1550
|
+
try:
|
|
1551
|
+
result = disputes_mod.check_freshness(
|
|
1552
|
+
args.repo, args.repo, args.span, entries,
|
|
1553
|
+
inventory_ids)
|
|
1554
|
+
except ValueError as exc:
|
|
1555
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
1556
|
+
return 2
|
|
1557
|
+
scope = f"span {args.span}"
|
|
1558
|
+
else:
|
|
1559
|
+
inv = disputes_mod.build_inventory(
|
|
1560
|
+
args.repo, entries, overrides)
|
|
1561
|
+
result = {"violations": [
|
|
1562
|
+
row for row in inv["open"]
|
|
1563
|
+
if row["id"] not in inventory_ids],
|
|
1564
|
+
"touching": sorted({row["record"] for row in
|
|
1565
|
+
inv["open"]}),
|
|
1566
|
+
"unscoped": []}
|
|
1567
|
+
scope = "all records"
|
|
1568
|
+
for row in result["violations"]:
|
|
1569
|
+
print(f" UNINVENTORIED {row['id']} "
|
|
1570
|
+
f"({row['severity']}/{row['status']}) "
|
|
1571
|
+
f"{row['record']}: {row['title']}")
|
|
1572
|
+
for rel in result["unscoped"]:
|
|
1573
|
+
print(f" note: record {rel} carries no range/head; "
|
|
1574
|
+
f"it cannot be scoped to the span and was not "
|
|
1575
|
+
f"checked", file=sys.stderr)
|
|
1576
|
+
if result["violations"]:
|
|
1577
|
+
print(f"codereview: freshness check FAILED "
|
|
1578
|
+
f"({scope}): {len(result['violations'])} "
|
|
1579
|
+
f"open dispute(s) neither dispositioned nor "
|
|
1580
|
+
f"inventoried")
|
|
1581
|
+
return 1
|
|
1582
|
+
print(f"codereview: freshness check ok ({scope}): no "
|
|
1583
|
+
f"un-inventoried open disputes")
|
|
1584
|
+
return 0
|
|
1585
|
+
|
|
1586
|
+
inv = disputes_mod.build_inventory(args.repo, entries,
|
|
1587
|
+
overrides)
|
|
1588
|
+
if args.json:
|
|
1589
|
+
print(json.dumps(inv, ensure_ascii=False, indent=2))
|
|
1590
|
+
return 0
|
|
1591
|
+
by_record = {}
|
|
1592
|
+
for row in inv["open"]:
|
|
1593
|
+
by_record.setdefault(row["record"], []).append(row)
|
|
1594
|
+
for rel in sorted(by_record):
|
|
1595
|
+
print(f"{rel}:")
|
|
1596
|
+
for row in by_record[rel]:
|
|
1597
|
+
tag = row["authority"]
|
|
1598
|
+
raiser = (row["raiser_family"] or row["raiser"]
|
|
1599
|
+
or "?")
|
|
1600
|
+
refuter = (row["refuter_family"] or row["refuter"]
|
|
1601
|
+
or "?")
|
|
1602
|
+
loc = (f"{row['file']}:{row['line']}"
|
|
1603
|
+
if row["line"] else row["file"])
|
|
1604
|
+
if tag["authority_affecting"]:
|
|
1605
|
+
auth = "YES — " + ", ".join(
|
|
1606
|
+
tag["dimensions"] or ["(override)"])
|
|
1607
|
+
else:
|
|
1608
|
+
auth = "no"
|
|
1609
|
+
print(f" {row['id']} {row['severity']} "
|
|
1610
|
+
f"{row['status']} {loc} "
|
|
1611
|
+
f"[{raiser} -> refuted by {refuter}]")
|
|
1612
|
+
print(f" {row['title']}")
|
|
1613
|
+
print(f" authority-affecting: {auth}")
|
|
1614
|
+
n_auth = sum(1 for r in inv["open"]
|
|
1615
|
+
if r["authority"]["authority_affecting"])
|
|
1616
|
+
print(f"codereview: {len(inv['open'])} open dispute(s) "
|
|
1617
|
+
f"across {inv['records_scanned']} record(s) "
|
|
1618
|
+
f"({n_auth} authority-affecting); "
|
|
1619
|
+
f"{len(inv['dispositioned'])} dispute(s) already "
|
|
1620
|
+
f"dispositioned in the store ({inv['store_entries']} "
|
|
1621
|
+
f"entr(ies) at {store_path})")
|
|
1622
|
+
return 0
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _locate_main(argv):
|
|
1626
|
+
"""codereview locate <finding-id> --record <path> — resolve
|
|
1627
|
+
a finding's validated quote to its blob position(s) at the
|
|
1628
|
+
reviewed commit (§5.6)."""
|
|
1629
|
+
ap = argparse.ArgumentParser(
|
|
1630
|
+
prog="codereview locate",
|
|
1631
|
+
description="Resolve a finding's validated evidence "
|
|
1632
|
+
"quote to its position(s) in the cited file "
|
|
1633
|
+
"as committed at the record's reviewed "
|
|
1634
|
+
"commit. The quote is the anchor; the cited "
|
|
1635
|
+
"line number is only checked against it.")
|
|
1636
|
+
ap.add_argument("finding_id",
|
|
1637
|
+
help="the finding's fingerprint (id)")
|
|
1638
|
+
ap.add_argument("--record", required=True,
|
|
1639
|
+
help="record directory or record JSON file")
|
|
1640
|
+
ap.add_argument("--repo", default=".",
|
|
1641
|
+
help="repository the record belongs to")
|
|
1642
|
+
args = ap.parse_args(argv)
|
|
1643
|
+
try:
|
|
1644
|
+
record, record_dir, jpath = _load_record_arg(
|
|
1645
|
+
args.record, args.repo)
|
|
1646
|
+
except Exception as exc: # noqa: BLE001
|
|
1647
|
+
print(f"codereview: could not read the record: {exc}",
|
|
1648
|
+
file=sys.stderr)
|
|
1649
|
+
return 2
|
|
1650
|
+
result = disputes_mod.locate_finding(
|
|
1651
|
+
record, args.finding_id, args.repo, record_dir)
|
|
1652
|
+
if result is None:
|
|
1653
|
+
print(f"codereview: finding {args.finding_id} is not in "
|
|
1654
|
+
f"{jpath}", file=sys.stderr)
|
|
1655
|
+
return 2
|
|
1656
|
+
print(f"finding {result['finding_id']}: {result['title']}")
|
|
1657
|
+
print(f" file: {result['file']} cited line: "
|
|
1658
|
+
f"{result['cited_line']} line_basis: "
|
|
1659
|
+
f"{result['line_basis'] or '(not recorded)'}")
|
|
1660
|
+
print(f" quote ({result['quote_source']}, from "
|
|
1661
|
+
f"{result['quote_from']}): {result['quote'][:200]}")
|
|
1662
|
+
if result["blob_error"]:
|
|
1663
|
+
print(f" blob at {result['blob_ref']}: unreadable — "
|
|
1664
|
+
f"{result['blob_error']}")
|
|
1665
|
+
return 1
|
|
1666
|
+
if not result["positions"]:
|
|
1667
|
+
print(f" quote not found in {result['file']} at "
|
|
1668
|
+
f"{result['blob_ref'][:12]}… — the citation does "
|
|
1669
|
+
f"not resolve at the reviewed commit")
|
|
1670
|
+
return 1
|
|
1671
|
+
print(f" blob positions at {result['blob_ref'][:12]}…: "
|
|
1672
|
+
f"{result['positions']}")
|
|
1673
|
+
resolves = ("yes" if result["cited_matches"] else
|
|
1674
|
+
"NO — the numbering basis differs "
|
|
1675
|
+
"(see line_basis)")
|
|
1676
|
+
print(f" cited line resolves: {resolves}")
|
|
1677
|
+
return 0
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _sarif_main(argv):
|
|
1681
|
+
"""codereview sarif — export a saved record's open findings
|
|
1682
|
+
as a SARIF 2.1.0 log (Epic D, ticket D1), for GitHub code
|
|
1683
|
+
scanning and other SARIF consumers. An export, not a gate:
|
|
1684
|
+
exit 0 means the log was written, whatever the record's
|
|
1685
|
+
gate was; exit 2 means the record could not be read."""
|
|
1686
|
+
ap = argparse.ArgumentParser(
|
|
1687
|
+
prog="codereview sarif",
|
|
1688
|
+
description="Export a saved review record as SARIF "
|
|
1689
|
+
"2.1.0 (open findings only; see "
|
|
1690
|
+
"codereview.sarif for the mapping).")
|
|
1691
|
+
ap.add_argument("path",
|
|
1692
|
+
help="record directory or record JSON file")
|
|
1693
|
+
ap.add_argument("--repo", default=".",
|
|
1694
|
+
help="repository the record belongs to")
|
|
1695
|
+
ap.add_argument("--out",
|
|
1696
|
+
help="write the SARIF log here "
|
|
1697
|
+
"(default: stdout)")
|
|
1698
|
+
args = ap.parse_args(argv)
|
|
1699
|
+
try:
|
|
1700
|
+
record, _record_dir, jpath = _load_record_arg(
|
|
1701
|
+
args.path, args.repo)
|
|
1702
|
+
except Exception as exc: # noqa: BLE001
|
|
1703
|
+
print(f"codereview: could not read the record: {exc}",
|
|
1704
|
+
file=sys.stderr)
|
|
1705
|
+
return 2
|
|
1706
|
+
log = sarif_mod.record_to_sarif(record)
|
|
1707
|
+
text = json.dumps(log, ensure_ascii=False, indent=2) + "\n"
|
|
1708
|
+
run = log["runs"][0]
|
|
1709
|
+
if args.out:
|
|
1710
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
1711
|
+
fh.write(text)
|
|
1712
|
+
print(f"codereview sarif: {jpath} -> {args.out} "
|
|
1713
|
+
f"({len(run['results'])} result(s), "
|
|
1714
|
+
f"{len(run['tool']['driver']['rules'])} rule(s); "
|
|
1715
|
+
f"record gate = "
|
|
1716
|
+
f"{str(record.get('gate') or 'unknown').upper()})")
|
|
1717
|
+
else:
|
|
1718
|
+
sys.stdout.write(text)
|
|
1719
|
+
return 0
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _mcp_main(argv):
|
|
1723
|
+
"""codereview mcp serve — serve the gate over the Model
|
|
1724
|
+
Context Protocol (Epic L2): a stdio server exposing
|
|
1725
|
+
run_gate / get_verdict so an MCP-speaking agent can invoke
|
|
1726
|
+
the gate from inside its own loop. Serving only — the
|
|
1727
|
+
server is a front door onto this same CLI (a run goes
|
|
1728
|
+
through main() itself), holds no credentials of its own,
|
|
1729
|
+
and draws seats only from the saved setup / environment,
|
|
1730
|
+
exactly like a CLI run. Protocol on stdout; all
|
|
1731
|
+
diagnostics on stderr."""
|
|
1732
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
1733
|
+
ap = argparse.ArgumentParser(
|
|
1734
|
+
prog="codereview mcp",
|
|
1735
|
+
description="Serve the CodeReview gate over the "
|
|
1736
|
+
"Model Context Protocol.")
|
|
1737
|
+
ap.add_argument("action", choices=("serve",),
|
|
1738
|
+
help="serve — serve the gate on stdio "
|
|
1739
|
+
"(tools: run_gate, get_verdict)")
|
|
1740
|
+
ap.parse_args(argv)
|
|
1741
|
+
if not argv or argv[0] != "serve":
|
|
1742
|
+
print("usage: codereview mcp serve", file=sys.stderr)
|
|
1743
|
+
return 2
|
|
1744
|
+
ap = argparse.ArgumentParser(
|
|
1745
|
+
prog="codereview mcp serve",
|
|
1746
|
+
description="Serve the CodeReview gate over MCP "
|
|
1747
|
+
"(stdio transport; tools: run_gate, "
|
|
1748
|
+
"get_verdict).")
|
|
1749
|
+
ap.parse_args(argv[1:])
|
|
1750
|
+
from . import mcp_tools
|
|
1751
|
+
print(f"codereview mcp: serving the gate on stdio "
|
|
1752
|
+
f"(server {mcp_tools.SERVER_INFO['name']} "
|
|
1753
|
+
f"{mcp_tools.SERVER_INFO['version']}; seats from "
|
|
1754
|
+
f"your saved setup)", file=sys.stderr)
|
|
1755
|
+
mcp_tools.serve_stdio()
|
|
1756
|
+
return 0
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def _policy_main(argv):
|
|
1760
|
+
"""codereview policy check — validate the project's policy
|
|
1761
|
+
file (.codereview.toml, Epic B) and print the effective
|
|
1762
|
+
policy: the file's rules with every default filled in. With
|
|
1763
|
+
no policy file, the effective policy is the defaults — no
|
|
1764
|
+
requirements — and that is a valid state, printed as such."""
|
|
1765
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
1766
|
+
ap = argparse.ArgumentParser(
|
|
1767
|
+
prog="codereview policy",
|
|
1768
|
+
description="Validate and print the effective "
|
|
1769
|
+
"project policy.")
|
|
1770
|
+
ap.add_argument("action", choices=("check",),
|
|
1771
|
+
help="check — validate the policy file "
|
|
1772
|
+
"and print the effective policy")
|
|
1773
|
+
ap.parse_args(argv)
|
|
1774
|
+
if not argv or argv[0] != "check":
|
|
1775
|
+
print("usage: codereview policy check [--repo DIR] "
|
|
1776
|
+
"[--policy PATH]", file=sys.stderr)
|
|
1777
|
+
return 2
|
|
1778
|
+
ap = argparse.ArgumentParser(
|
|
1779
|
+
prog="codereview policy check",
|
|
1780
|
+
description="Validate the project policy file and print "
|
|
1781
|
+
"the effective policy (defaults filled in).")
|
|
1782
|
+
ap.add_argument("--repo", default=".",
|
|
1783
|
+
help="repository root (default: .); the "
|
|
1784
|
+
"policy file is .codereview.toml there")
|
|
1785
|
+
ap.add_argument("--policy",
|
|
1786
|
+
help="policy file path (default: "
|
|
1787
|
+
"<repo>/.codereview.toml)")
|
|
1788
|
+
args = ap.parse_args(argv[1:])
|
|
1789
|
+
path = args.policy or os.path.join(
|
|
1790
|
+
args.repo, policy_mod.POLICY_FILENAME)
|
|
1791
|
+
if not os.path.exists(path):
|
|
1792
|
+
print(f"codereview policy: no policy file at {path} — "
|
|
1793
|
+
f"the default policy is in effect (no "
|
|
1794
|
+
f"requirements):")
|
|
1795
|
+
print(policy_mod.render_effective(
|
|
1796
|
+
policy_mod.default_policy()))
|
|
1797
|
+
return 0
|
|
1798
|
+
try:
|
|
1799
|
+
loaded = policy_mod.load_policy(path)
|
|
1800
|
+
except Exception as exc: # noqa: BLE001
|
|
1801
|
+
print(f"codereview policy: {exc}", file=sys.stderr)
|
|
1802
|
+
return 2
|
|
1803
|
+
print(f"codereview policy: {path} is valid "
|
|
1804
|
+
f"(sha256 {loaded['sha256'][:12]}…). Effective policy:")
|
|
1805
|
+
print(policy_mod.render_effective(loaded))
|
|
1806
|
+
return 0
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
def _bench_main(argv, providers_override=None):
|
|
1810
|
+
"""codereview bench run|report — Epic A benchmark harness."""
|
|
1811
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
1812
|
+
ap = argparse.ArgumentParser(
|
|
1813
|
+
prog="codereview bench",
|
|
1814
|
+
description="Run the benchmark corpus and "
|
|
1815
|
+
"reports (Epic A harness).")
|
|
1816
|
+
ap.add_argument("action", choices=("run", "report"),
|
|
1817
|
+
help="run — run the corpus under a "
|
|
1818
|
+
"config; report — summarise saved "
|
|
1819
|
+
"results")
|
|
1820
|
+
ap.parse_args(argv)
|
|
1821
|
+
if not argv or argv[0] not in ("run", "report"):
|
|
1822
|
+
print("usage: codereview bench run --config NAME [...] "
|
|
1823
|
+
"[--corpus DIR] [--out DIR] [--split all|public|holdout]\n"
|
|
1824
|
+
" codereview bench report [--results DIR] "
|
|
1825
|
+
"[--corpus DIR] [--public] [--out-dir DIR]\n"
|
|
1826
|
+
" [--max-escaped-rate RATE]",
|
|
1827
|
+
file=sys.stderr)
|
|
1828
|
+
return 2
|
|
1829
|
+
action, rest = argv[0], argv[1:]
|
|
1830
|
+
if action == "run":
|
|
1831
|
+
ap = argparse.ArgumentParser(prog="codereview bench run")
|
|
1832
|
+
ap.add_argument("--config", action="append", required=True,
|
|
1833
|
+
help="config name (bench/configs/<name>.json) "
|
|
1834
|
+
"or path to a config JSON; repeatable")
|
|
1835
|
+
ap.add_argument("--corpus", default="bench/corpus",
|
|
1836
|
+
help="corpus directory (default: bench/corpus)")
|
|
1837
|
+
ap.add_argument("--out", default="bench-out",
|
|
1838
|
+
help="results directory (default: bench-out); "
|
|
1839
|
+
"runs resume — recorded cases are skipped")
|
|
1840
|
+
ap.add_argument("--split", default="all",
|
|
1841
|
+
choices=["all", "public", "holdout"],
|
|
1842
|
+
help="restrict to one corpus split")
|
|
1843
|
+
ap.add_argument("--force", action="store_true",
|
|
1844
|
+
help="discard existing results for the named "
|
|
1845
|
+
"configs and re-run every case")
|
|
1846
|
+
args = ap.parse_args(rest)
|
|
1847
|
+
if _refuse_unwritable_out(args.out, "codereview bench"):
|
|
1848
|
+
return 2
|
|
1849
|
+
injected = None
|
|
1850
|
+
if providers_override is not None:
|
|
1851
|
+
injected = {}
|
|
1852
|
+
for spec in args.config:
|
|
1853
|
+
try:
|
|
1854
|
+
name = bench_mod.load_config(
|
|
1855
|
+
bench_mod.resolve_config_path(
|
|
1856
|
+
spec, args.corpus))["name"]
|
|
1857
|
+
except Exception: # noqa: BLE001
|
|
1858
|
+
continue
|
|
1859
|
+
injected[name] = providers_override
|
|
1860
|
+
try:
|
|
1861
|
+
results = bench_mod.run_bench(
|
|
1862
|
+
args.corpus, args.config, args.out, split=args.split,
|
|
1863
|
+
force=args.force, providers_by_config=injected)
|
|
1864
|
+
except Exception as exc: # noqa: BLE001
|
|
1865
|
+
print(f"codereview bench: {exc}", file=sys.stderr)
|
|
1866
|
+
return 2
|
|
1867
|
+
for name, records in results.items():
|
|
1868
|
+
gates = {}
|
|
1869
|
+
for rec in records:
|
|
1870
|
+
gates[rec["gate"]] = gates.get(rec["gate"], 0) + 1
|
|
1871
|
+
print(f"codereview bench: {name}: {len(records)} case "
|
|
1872
|
+
f"record(s) in {args.out} "
|
|
1873
|
+
f"(gates: {json.dumps(gates, sort_keys=True)})")
|
|
1874
|
+
return 0
|
|
1875
|
+
ap = argparse.ArgumentParser(prog="codereview bench report")
|
|
1876
|
+
ap.add_argument("--results", default="bench-out",
|
|
1877
|
+
help="results directory written by bench run "
|
|
1878
|
+
"(default: bench-out)")
|
|
1879
|
+
ap.add_argument("--corpus", default="bench/corpus",
|
|
1880
|
+
help="corpus directory (default: bench/corpus)")
|
|
1881
|
+
ap.add_argument("--public", action="store_true",
|
|
1882
|
+
help="public report: holdout split excluded "
|
|
1883
|
+
"before scoring (ids and rows never appear)")
|
|
1884
|
+
ap.add_argument("--out-dir",
|
|
1885
|
+
help="also write bench-report.md and "
|
|
1886
|
+
"bench-report.json here")
|
|
1887
|
+
ap.add_argument("--max-escaped-rate", type=float,
|
|
1888
|
+
help="exit 1 if any config's escaped-defect rate "
|
|
1889
|
+
"exceeds this rate (CI regression gate)")
|
|
1890
|
+
args = ap.parse_args(rest)
|
|
1891
|
+
if _refuse_unwritable_out(args.out_dir, "codereview bench"):
|
|
1892
|
+
return 2
|
|
1893
|
+
try:
|
|
1894
|
+
report = bench_mod.build_report(
|
|
1895
|
+
bench_mod.load_results(args.results), args.corpus,
|
|
1896
|
+
public_only=args.public)
|
|
1897
|
+
except Exception as exc: # noqa: BLE001
|
|
1898
|
+
print(f"codereview bench: {exc}", file=sys.stderr)
|
|
1899
|
+
return 2
|
|
1900
|
+
md = bench_mod.render_markdown(report)
|
|
1901
|
+
print(md)
|
|
1902
|
+
if args.out_dir:
|
|
1903
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
1904
|
+
stem = "bench-report-public" if args.public else "bench-report"
|
|
1905
|
+
with open(os.path.join(args.out_dir, stem + ".md"), "w",
|
|
1906
|
+
encoding="utf-8") as fh:
|
|
1907
|
+
fh.write(md)
|
|
1908
|
+
with open(os.path.join(args.out_dir, stem + ".json"), "w",
|
|
1909
|
+
encoding="utf-8") as fh:
|
|
1910
|
+
json.dump(report, fh, indent=2)
|
|
1911
|
+
print(f"codereview bench: wrote {stem}.md / {stem}.json "
|
|
1912
|
+
f"to {args.out_dir}")
|
|
1913
|
+
if args.max_escaped_rate is not None:
|
|
1914
|
+
worst = [(name, e["metrics"]["escaped_defect_rate"])
|
|
1915
|
+
for name, e in report["configs"].items()
|
|
1916
|
+
if e["metrics"]["escaped_defect_rate"] is not None
|
|
1917
|
+
and e["metrics"]["escaped_defect_rate"]
|
|
1918
|
+
> args.max_escaped_rate]
|
|
1919
|
+
if worst:
|
|
1920
|
+
for name, rate in worst:
|
|
1921
|
+
print(f"codereview bench: {name} escaped-defect rate "
|
|
1922
|
+
f"{rate:.3f} exceeds --max-escaped-rate "
|
|
1923
|
+
f"{args.max_escaped_rate}", file=sys.stderr)
|
|
1924
|
+
return 1
|
|
1925
|
+
return 0
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _acceptance_main(argv, providers_override=None):
|
|
1929
|
+
"""codereview acceptance — run the shadow Tech Lead over
|
|
1930
|
+
the known-answer acceptance corpus and score the adopted
|
|
1931
|
+
gates M1–M6 (refutation-process solution §6). The seat is
|
|
1932
|
+
built from --provider specs (or supplied by the caller via
|
|
1933
|
+
providers_override, as the swarm runner supplies its
|
|
1934
|
+
seats); its family must be a third family relative to the
|
|
1935
|
+
corpus's raiser/refuter families for the run to mean
|
|
1936
|
+
anything."""
|
|
1937
|
+
ap = argparse.ArgumentParser(prog="codereview acceptance")
|
|
1938
|
+
ap.add_argument("--corpus", required=True,
|
|
1939
|
+
help="path to the acceptance corpus "
|
|
1940
|
+
"directory (index.json + cases/ + "
|
|
1941
|
+
"scorecard.json + mutations.json)")
|
|
1942
|
+
ap.add_argument("--provider", action="append", default=[],
|
|
1943
|
+
help="provider spec for the Tech Lead seat, "
|
|
1944
|
+
"e.g. deepseek:deepseek-chat (the "
|
|
1945
|
+
"first seat is used)")
|
|
1946
|
+
ap.add_argument("--seat-family", default=None,
|
|
1947
|
+
help="family tag for the seat (defaults to "
|
|
1948
|
+
"the seat's provider family)")
|
|
1949
|
+
ap.add_argument("--out", default=None,
|
|
1950
|
+
help="write the full run record (JSON) to "
|
|
1951
|
+
"this directory")
|
|
1952
|
+
ap.add_argument("--no-mutations", action="store_true",
|
|
1953
|
+
help="skip the M4 mutation battery")
|
|
1954
|
+
ap.add_argument("--timeout", type=int, default=180,
|
|
1955
|
+
help="per-call timeout in seconds")
|
|
1956
|
+
args = ap.parse_args(argv)
|
|
1957
|
+
if providers_override:
|
|
1958
|
+
seats = dict(providers_override)
|
|
1959
|
+
elif args.provider:
|
|
1960
|
+
try:
|
|
1961
|
+
seats = build_providers(
|
|
1962
|
+
args.provider, agent_timeout=args.timeout)
|
|
1963
|
+
except Exception as exc: # noqa: BLE001
|
|
1964
|
+
print(f"codereview: provider setup failed: {exc}",
|
|
1965
|
+
file=sys.stderr)
|
|
1966
|
+
return 2
|
|
1967
|
+
else:
|
|
1968
|
+
print("codereview: acceptance needs a seat: pass "
|
|
1969
|
+
"--provider <spec>", file=sys.stderr)
|
|
1970
|
+
return 2
|
|
1971
|
+
if not seats:
|
|
1972
|
+
print("codereview: no seat available", file=sys.stderr)
|
|
1973
|
+
return 2
|
|
1974
|
+
seat_name, seat_fn = next(iter(seats.items()))
|
|
1975
|
+
seat_family = args.seat_family or getattr(
|
|
1976
|
+
seat_fn, "_codereview_family", None) or seat_name
|
|
1977
|
+
from . import acceptance as acceptance_mod
|
|
1978
|
+
result = acceptance_mod.run_acceptance(
|
|
1979
|
+
args.corpus, seat_fn, seat_family=seat_family,
|
|
1980
|
+
seat_name=seat_name,
|
|
1981
|
+
do_mutations=not args.no_mutations,
|
|
1982
|
+
timeout=args.timeout,
|
|
1983
|
+
log=lambda m: print(m, file=sys.stderr))
|
|
1984
|
+
for mid, m in result["metrics"].items():
|
|
1985
|
+
print(f"{mid}: measured={m['measured']} "
|
|
1986
|
+
f"threshold={m['threshold']} "
|
|
1987
|
+
f"pass={m['pass']}")
|
|
1988
|
+
print(f"unlock (all gates on one run): "
|
|
1989
|
+
f"{result['unlock_all_gates_pass']}")
|
|
1990
|
+
if args.out:
|
|
1991
|
+
os.makedirs(args.out, exist_ok=True)
|
|
1992
|
+
path = os.path.join(args.out, "acceptance-run.json")
|
|
1993
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
1994
|
+
json.dump(result, fh, indent=1, default=str)
|
|
1995
|
+
print(f"record: {path}")
|
|
1996
|
+
return 0 if result["unlock_all_gates_pass"] else 1
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def _seat_progress(event):
|
|
2000
|
+
"""Live seat-state line (Epic K2): one stderr line per seat
|
|
2001
|
+
transition while a review/verification round runs, so a
|
|
2002
|
+
seat stuck on its plan's quota, a login, or a stall is
|
|
2003
|
+
visible as it happens — the same transitions the record
|
|
2004
|
+
persists per seat (seat_state). A seat that ends blocked
|
|
2005
|
+
on quota gets the spec's operator guidance; the panel's
|
|
2006
|
+
degraded handling itself is the gate's, unchanged."""
|
|
2007
|
+
state = event.get("state") or ""
|
|
2008
|
+
cls = event.get("class")
|
|
2009
|
+
label = f"{state}:{cls}" if cls else state
|
|
2010
|
+
print(f"codereview: seat {event.get('seat')}: {label}",
|
|
2011
|
+
file=sys.stderr)
|
|
2012
|
+
if event.get("terminal") and state == "blocked" \
|
|
2013
|
+
and cls == "quota":
|
|
2014
|
+
print(f"codereview: seat {event.get('seat')} hit its "
|
|
2015
|
+
f"plan's allowance; the panel continues degraded "
|
|
2016
|
+
f"— top up or wait for the reset.", file=sys.stderr)
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def main(argv=None, providers_override=None, seat_meta_override=None,
|
|
2020
|
+
fallbacks_override=None):
|
|
2021
|
+
# P3-04: the gate process holds provider keys in its
|
|
2022
|
+
# environment and runs untrusted commands (evidence,
|
|
2023
|
+
# probes, agent seats); make it non-dumpable so a child
|
|
2024
|
+
# cannot read that environment back via /proc/$PPID.
|
|
2025
|
+
hardening.make_non_dumpable()
|
|
2026
|
+
if argv is None:
|
|
2027
|
+
argv = sys.argv[1:]
|
|
2028
|
+
if argv and argv[0] == "setup":
|
|
2029
|
+
return _setup_main(argv[1:])
|
|
2030
|
+
if argv and argv[0] == "evidence":
|
|
2031
|
+
return _evidence_main(argv[1:])
|
|
2032
|
+
if argv and argv[0] == "acceptance":
|
|
2033
|
+
return _acceptance_main(
|
|
2034
|
+
argv[1:], providers_override=providers_override)
|
|
2035
|
+
if argv and argv[0] == "doctor":
|
|
2036
|
+
return _doctor_main(argv[1:],
|
|
2037
|
+
providers_override=providers_override,
|
|
2038
|
+
fallbacks_override=fallbacks_override)
|
|
2039
|
+
if argv and argv[0] == "record-verify":
|
|
2040
|
+
return _record_verify_main(argv[1:])
|
|
2041
|
+
if argv and argv[0] == "policy":
|
|
2042
|
+
return _policy_main(argv[1:])
|
|
2043
|
+
if argv and argv[0] == "sarif":
|
|
2044
|
+
return _sarif_main(argv[1:])
|
|
2045
|
+
if argv and argv[0] == "disputes":
|
|
2046
|
+
return _disputes_main(argv[1:])
|
|
2047
|
+
if argv and argv[0] == "locate":
|
|
2048
|
+
return _locate_main(argv[1:])
|
|
2049
|
+
if argv and argv[0] == "bench":
|
|
2050
|
+
return _bench_main(argv[1:],
|
|
2051
|
+
providers_override=providers_override)
|
|
2052
|
+
if argv and argv[0] == "pr":
|
|
2053
|
+
return _pr_main(argv[1:],
|
|
2054
|
+
providers_override=providers_override)
|
|
2055
|
+
if argv and argv[0] == "workflow":
|
|
2056
|
+
return _workflow_main(argv[1:],
|
|
2057
|
+
providers_override=providers_override)
|
|
2058
|
+
if argv and argv[0] == "loop":
|
|
2059
|
+
return _loop_main(argv[1:],
|
|
2060
|
+
providers_override=providers_override)
|
|
2061
|
+
if argv and argv[0] == "intake":
|
|
2062
|
+
return _intake_main(argv[1:],
|
|
2063
|
+
solvers_override=providers_override)
|
|
2064
|
+
if argv and argv[0] == "mcp":
|
|
2065
|
+
return _mcp_main(argv[1:])
|
|
2066
|
+
if argv and argv[0] == "support-bundle":
|
|
2067
|
+
from . import support
|
|
2068
|
+
return support.support_main(argv[1:])
|
|
2069
|
+
if argv and argv[0] == "report":
|
|
2070
|
+
from . import feedback
|
|
2071
|
+
return feedback.report_main(argv[1:])
|
|
2072
|
+
if argv and argv[0] == "telemetry":
|
|
2073
|
+
from . import feedback
|
|
2074
|
+
return feedback.telemetry_main(argv[1:])
|
|
2075
|
+
ap = argparse.ArgumentParser(
|
|
2076
|
+
prog="codereview",
|
|
2077
|
+
description="Multi-model code review loop with a "
|
|
2078
|
+
"deterministic gate.",
|
|
2079
|
+
epilog="Subcommands (each has its own --help; running "
|
|
2080
|
+
"one replaces the review flags above): "
|
|
2081
|
+
"setup — first-run wizard: pick providers, keys "
|
|
2082
|
+
"and per-role models; "
|
|
2083
|
+
"doctor — probe the configured seats; "
|
|
2084
|
+
"policy — validate and print the effective "
|
|
2085
|
+
"project policy; "
|
|
2086
|
+
"record-verify — verify a stored run record "
|
|
2087
|
+
"offline; "
|
|
2088
|
+
"disputes — list and record rulings on disputed "
|
|
2089
|
+
"findings; "
|
|
2090
|
+
"bench — run the benchmark corpus and reports; "
|
|
2091
|
+
"intake — turn an issue report into a reviewed "
|
|
2092
|
+
"solution; "
|
|
2093
|
+
"pr — review a GitHub pull request in place; "
|
|
2094
|
+
"workflow — run a saved multi-step review "
|
|
2095
|
+
"workflow; "
|
|
2096
|
+
"loop — repeat review rounds until the gate "
|
|
2097
|
+
"passes; "
|
|
2098
|
+
"sarif — export a record's findings as SARIF; "
|
|
2099
|
+
"evidence — run an evidence config standalone; "
|
|
2100
|
+
"locate — resolve a cited quote to blob "
|
|
2101
|
+
"positions; "
|
|
2102
|
+
"acceptance — run the acceptance harness; "
|
|
2103
|
+
"mcp — serve the gate over the Model Context "
|
|
2104
|
+
"Protocol (stdio; run_gate / get_verdict); "
|
|
2105
|
+
"support-bundle — write one redacted zip to attach "
|
|
2106
|
+
"to a bug report (nothing is sent); "
|
|
2107
|
+
"report — send a redacted bug report after you "
|
|
2108
|
+
"confirm it; "
|
|
2109
|
+
"telemetry — opt in or out of the anonymous crash "
|
|
2110
|
+
"ping (off by default). If something goes wrong, "
|
|
2111
|
+
"see SECURITY.md and CONTRIBUTING.md.")
|
|
2112
|
+
ap.add_argument("--version", action="version",
|
|
2113
|
+
version=core.VERSION,
|
|
2114
|
+
help="print the codereview version and exit")
|
|
2115
|
+
src = ap.add_argument_group("change source (pick one)")
|
|
2116
|
+
src.add_argument("--patch", help="unified diff file")
|
|
2117
|
+
src.add_argument("--repo", help="git repository path (default: .)")
|
|
2118
|
+
src.add_argument("--range", dest="range",
|
|
2119
|
+
help="git diff range, e.g. main...HEAD")
|
|
2120
|
+
src.add_argument("--staged", action="store_true",
|
|
2121
|
+
help="review the staged diff")
|
|
2122
|
+
src.add_argument("--files", nargs="+",
|
|
2123
|
+
help="review these files in full (new files)")
|
|
2124
|
+
ap.add_argument("--provider", action="append", default=[],
|
|
2125
|
+
help="provider preset (openai, gemini, anthropic, "
|
|
2126
|
+
"deepseek, minimax, moonshot, nvidia, xai, "
|
|
2127
|
+
"mistral, openrouter; preset:model to pick "
|
|
2128
|
+
"the model) or name|type|base_url|model|"
|
|
2129
|
+
"key_env; repeatable. Default: "
|
|
2130
|
+
"$CODEREVIEW_PROVIDERS or your saved setup "
|
|
2131
|
+
"(codereview setup)")
|
|
2132
|
+
ap.add_argument("--context", default="",
|
|
2133
|
+
help="builder's change description (or @file)")
|
|
2134
|
+
ap.add_argument("--test-command", action="append", default=[],
|
|
2135
|
+
help="test command to run and include; repeatable")
|
|
2136
|
+
ap.add_argument("--evidence-config",
|
|
2137
|
+
help="evidence runner config (JSON; Epic C): "
|
|
2138
|
+
"configured test/lint/SAST commands and "
|
|
2139
|
+
"security adapters run in a sandboxed copy "
|
|
2140
|
+
"of the tree (scrubbed environment, no "
|
|
2141
|
+
"network by default), the hashed evidence "
|
|
2142
|
+
"record is stored in the report, and "
|
|
2143
|
+
"required evidence that fails BLOCKS the "
|
|
2144
|
+
"gate (unavailable required evidence is "
|
|
2145
|
+
"INCONCLUSIVE). The config's shape matches "
|
|
2146
|
+
"the Epic B policy file's [evidence] table")
|
|
2147
|
+
ap.add_argument("--out", default="codereview-out",
|
|
2148
|
+
help="output directory for the report (default: "
|
|
2149
|
+
"codereview-out)")
|
|
2150
|
+
ap.add_argument("--allow-major", action="store_true",
|
|
2151
|
+
help="only blockers block the gate (majors advisory)")
|
|
2152
|
+
ap.add_argument("--strict", action="store_true",
|
|
2153
|
+
help="degraded or truncated reviews are INCONCLUSIVE "
|
|
2154
|
+
"instead of passing")
|
|
2155
|
+
ap.add_argument("--gate-profile",
|
|
2156
|
+
choices=("strict", "balanced", "corroborated"),
|
|
2157
|
+
default=None,
|
|
2158
|
+
help="gate profile: 'strict' (the default — "
|
|
2159
|
+
"every blocking-severity finding blocks), "
|
|
2160
|
+
"'balanced' (policy B1: a blocking "
|
|
2161
|
+
"finding raised solely by the operations "
|
|
2162
|
+
"lens, with no correctness-lens or "
|
|
2163
|
+
"deterministic corroboration in the "
|
|
2164
|
+
"merged finding, is demoted to a visible "
|
|
2165
|
+
"minor), or 'corroborated' (policy B2: "
|
|
2166
|
+
"evidence-class blocking for every lens "
|
|
2167
|
+
"— a blocking finding needs deterministic, "
|
|
2168
|
+
"probe-confirmed, or cross-family-"
|
|
2169
|
+
"corroborated evidence, plus a validated "
|
|
2170
|
+
"quote and a concrete trigger; "
|
|
2171
|
+
"single-source findings become visible "
|
|
2172
|
+
"non-blocking unverified majors. Requires "
|
|
2173
|
+
"at least two distinct vendor families "
|
|
2174
|
+
"able to serve the correctness lens; "
|
|
2175
|
+
"single-family panels keep 'strict'. B2 "
|
|
2176
|
+
"is opt-in and unconfirmed — dev "
|
|
2177
|
+
"evidence only). Overrides the policy "
|
|
2178
|
+
"file's [gate] profile; the policy file "
|
|
2179
|
+
"overrides the default. The default "
|
|
2180
|
+
"stays 'strict'")
|
|
2181
|
+
ap.add_argument("--max-patch-chars", type=int, default=80000,
|
|
2182
|
+
help="per-batch diff budget (default 80000)")
|
|
2183
|
+
ap.add_argument("--no-file-context", action="store_true",
|
|
2184
|
+
help="do not attach post-change file contents "
|
|
2185
|
+
"to the reviewer prompts (default for "
|
|
2186
|
+
"repo reviews: the contents of each "
|
|
2187
|
+
"changed file — from the range target, "
|
|
2188
|
+
"the index, or the working tree — ride "
|
|
2189
|
+
"along as reference so definitions "
|
|
2190
|
+
"outside the diff hunks can be checked; "
|
|
2191
|
+
"capped per file and in total, with "
|
|
2192
|
+
"truncation marked in the text and "
|
|
2193
|
+
"listed in the record)")
|
|
2194
|
+
ap.add_argument("--no-agents-context", action="store_true",
|
|
2195
|
+
help="do not ingest AGENTS.md project "
|
|
2196
|
+
"guidance into the reviewer prompts "
|
|
2197
|
+
"(default for repo reviews: AGENTS.md at "
|
|
2198
|
+
"the repo root and nested along the "
|
|
2199
|
+
"changed paths is attached as advisory, "
|
|
2200
|
+
"untrusted context alongside — never "
|
|
2201
|
+
"instead of — .codereview.toml; its "
|
|
2202
|
+
"'## Code Review Rules' section is "
|
|
2203
|
+
"surfaced as rules the panel may cite; "
|
|
2204
|
+
"redacted, capped per file and in total "
|
|
2205
|
+
"with truncation recorded, provenance "
|
|
2206
|
+
"(path + sha256) written to the record)")
|
|
2207
|
+
ap.add_argument("--verify-previous",
|
|
2208
|
+
help="previous report JSON: run a verification round")
|
|
2209
|
+
ap.add_argument("--builder-response", default="",
|
|
2210
|
+
help="builder's response to prior findings (or @file)")
|
|
2211
|
+
ap.add_argument("--role", action="append", default=[],
|
|
2212
|
+
help="assign a model to a role: "
|
|
2213
|
+
"<role>=<provider-spec> with role one of "
|
|
2214
|
+
"correctness, operations, verifier, "
|
|
2215
|
+
"tech-lead, solver (e.g. "
|
|
2216
|
+
"tech-lead=deepseek:deepseek-reasoner, "
|
|
2217
|
+
"correctness=moonshot); repeatable. The "
|
|
2218
|
+
"role's seat joins the panel and the "
|
|
2219
|
+
"assignment is recorded in the report")
|
|
2220
|
+
ap.add_argument("--seat-meta", action="append", default=[],
|
|
2221
|
+
help="tag a seat for the record: name:family[:tier] "
|
|
2222
|
+
"(e.g. gemini:google:paid); repeatable")
|
|
2223
|
+
ap.add_argument("--fallback", action="append", default=[],
|
|
2224
|
+
help="fallback seat for a lens when its primary "
|
|
2225
|
+
"fails: lens=provider-spec (e.g. "
|
|
2226
|
+
"correctness=openai); repeatable")
|
|
2227
|
+
ap.add_argument("--fixture-manifest",
|
|
2228
|
+
help="versioned fixture manifest (JSON): the "
|
|
2229
|
+
"enumerated paths are intentionally "
|
|
2230
|
+
"vulnerable test DATA — their findings are "
|
|
2231
|
+
"classified in the record as expected "
|
|
2232
|
+
"fixture findings instead of blocking the "
|
|
2233
|
+
"gate. The manifest's hash, policy version "
|
|
2234
|
+
"and paths are recorded and recomputed by "
|
|
2235
|
+
"record-verify")
|
|
2236
|
+
ap.add_argument("--precedents",
|
|
2237
|
+
help="content-addressed precedent store "
|
|
2238
|
+
"(JSON): findings already adjudicated "
|
|
2239
|
+
"whose cited file is UNCHANGED (git blob "
|
|
2240
|
+
"identical to the disposition) are "
|
|
2241
|
+
"classified in the record as standing "
|
|
2242
|
+
"findings linked to their disposition "
|
|
2243
|
+
"instead of blocking again. The store's "
|
|
2244
|
+
"hash and every link are recorded and "
|
|
2245
|
+
"recomputed by record-verify")
|
|
2246
|
+
ap.add_argument("--policy",
|
|
2247
|
+
help="project policy file (TOML, Epic B): the "
|
|
2248
|
+
"panel, evidence, path and waiver rules "
|
|
2249
|
+
"the gate enforces for this repository. "
|
|
2250
|
+
"Default: .codereview.toml at the repo "
|
|
2251
|
+
"root, when present. An invalid policy "
|
|
2252
|
+
"file stops the run — it is never "
|
|
2253
|
+
"silently ignored")
|
|
2254
|
+
ap.add_argument("--trust-repo-policy", action="store_true",
|
|
2255
|
+
help="honour a policy DISCOVERED in the "
|
|
2256
|
+
"reviewed repository in full, including "
|
|
2257
|
+
"its [hooks] and gate-weakening keys. "
|
|
2258
|
+
"Without this flag a discovered policy "
|
|
2259
|
+
"keeps only its strengthening keys: "
|
|
2260
|
+
"hooks, a weaker-than-strict gate "
|
|
2261
|
+
"profile, advisory path rules, waiver "
|
|
2262
|
+
"leniency, diversity degrade/exclusion "
|
|
2263
|
+
"and risk tiering are ignored and named "
|
|
2264
|
+
"in the record's policy.ignored_keys "
|
|
2265
|
+
"(P3-01 trust restriction). A policy "
|
|
2266
|
+
"named with --policy is always honoured "
|
|
2267
|
+
"in full")
|
|
2268
|
+
ap.add_argument("--tech-lead",
|
|
2269
|
+
help="designate the seat that serves as Tech "
|
|
2270
|
+
"Lead for adjudicating refuted findings "
|
|
2271
|
+
"(owner amendment 2026-11-04): the Tech "
|
|
2272
|
+
"Lead's family must be independent of "
|
|
2273
|
+
"both the finding's raiser and its "
|
|
2274
|
+
"refuter — a designation that cannot "
|
|
2275
|
+
"serve independently is rejected and "
|
|
2276
|
+
"substituted, loudly, on the record. "
|
|
2277
|
+
"Without a designation, the first "
|
|
2278
|
+
"independent seat serves")
|
|
2279
|
+
ap.add_argument("--tech-lead-shadow", action="store_true",
|
|
2280
|
+
help="run the Tech Lead in SHADOW mode: for "
|
|
2281
|
+
"every disputed finding the full "
|
|
2282
|
+
"structured adjudication is produced and "
|
|
2283
|
+
"recorded in the record's "
|
|
2284
|
+
"shadow_adjudications section — blinded "
|
|
2285
|
+
"A/B positions, double-drawn for majors "
|
|
2286
|
+
"— but the decisions are non-binding and "
|
|
2287
|
+
"never change the gate verdict. The seat "
|
|
2288
|
+
"is the --tech-lead seat when designated, "
|
|
2289
|
+
"else an independent seat, else any "
|
|
2290
|
+
"served seat under reduced independence. "
|
|
2291
|
+
"Equivalent to the CODEREVIEW_TECH_LEAD_"
|
|
2292
|
+
"SHADOW=1 environment variable")
|
|
2293
|
+
ap.add_argument("--attempts-log",
|
|
2294
|
+
help="append one JSON line describing this run "
|
|
2295
|
+
"(seats, error classes, gate) to this file. "
|
|
2296
|
+
"Trust level: operator-supplied — the path "
|
|
2297
|
+
"comes from the command line only, never "
|
|
2298
|
+
"from repo content, policy, or config files "
|
|
2299
|
+
"(same trust as --out)")
|
|
2300
|
+
ap.add_argument("--private", action="store_true",
|
|
2301
|
+
help="the repository is private: seats tagged "
|
|
2302
|
+
"with a free tier are refused (free tiers "
|
|
2303
|
+
"may train on submitted code)")
|
|
2304
|
+
ap.add_argument("--timeout", type=int, default=None,
|
|
2305
|
+
help="per-seat timeout in seconds for agent "
|
|
2306
|
+
"seats (forwarded to provider construction "
|
|
2307
|
+
"for primary AND fallback seats, like "
|
|
2308
|
+
"doctor/intake); also the wait bound for "
|
|
2309
|
+
"Tech Lead adjudication calls")
|
|
2310
|
+
ap.add_argument("--probe-timeout", type=int, default=120,
|
|
2311
|
+
help="wall-clock bound in seconds for one "
|
|
2312
|
+
"harness probe (default 120)")
|
|
2313
|
+
ap.add_argument("--no-probe-harness", action="store_true",
|
|
2314
|
+
help="do not wire the probe harness: a Tech "
|
|
2315
|
+
"Lead probe request then escalates for "
|
|
2316
|
+
"lack of a channel, as before the "
|
|
2317
|
+
"harness existed")
|
|
2318
|
+
args = ap.parse_args(argv)
|
|
2319
|
+
|
|
2320
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
2321
|
+
print("codereview: --timeout must be a positive number "
|
|
2322
|
+
"of seconds", file=sys.stderr)
|
|
2323
|
+
return 2
|
|
2324
|
+
if _refuse_unwritable_out(args.out, "codereview"):
|
|
2325
|
+
return 2
|
|
2326
|
+
|
|
2327
|
+
# P3-09: an explicitly given --repo must be a real
|
|
2328
|
+
# directory. "" used to collapse to "." via `or`, and a
|
|
2329
|
+
# nonexistent path was accepted in BOTH modes — in patch
|
|
2330
|
+
# mode the run then silently skipped all project context
|
|
2331
|
+
# (policy, AGENTS.md, evidence) and still passed.
|
|
2332
|
+
if args.repo is not None:
|
|
2333
|
+
if args.repo == "":
|
|
2334
|
+
print("codereview: --repo must not be empty "
|
|
2335
|
+
"(omit it to review the current directory)",
|
|
2336
|
+
file=sys.stderr)
|
|
2337
|
+
return 2
|
|
2338
|
+
if not os.path.isdir(args.repo):
|
|
2339
|
+
print(f"codereview: --repo {args.repo!r} is not "
|
|
2340
|
+
f"an existing directory", file=sys.stderr)
|
|
2341
|
+
return 2
|
|
2342
|
+
|
|
2343
|
+
def _maybe_file(text):
|
|
2344
|
+
if text.startswith("@"):
|
|
2345
|
+
with open(os.path.expanduser(text[1:]), encoding="utf-8",
|
|
2346
|
+
errors="replace") as fh:
|
|
2347
|
+
return fh.read()
|
|
2348
|
+
return text
|
|
2349
|
+
|
|
2350
|
+
try:
|
|
2351
|
+
patch = _load_patch(args)
|
|
2352
|
+
except Exception as exc: # noqa: BLE001
|
|
2353
|
+
print(f"codereview: could not load the change: {exc}",
|
|
2354
|
+
file=sys.stderr)
|
|
2355
|
+
return 2
|
|
2356
|
+
fixture_scope = None
|
|
2357
|
+
if args.fixture_manifest:
|
|
2358
|
+
try:
|
|
2359
|
+
fixture_scope = _load_fixture_scope(
|
|
2360
|
+
args.fixture_manifest, repo=args.repo or ".")
|
|
2361
|
+
except Exception as exc: # noqa: BLE001
|
|
2362
|
+
print(f"codereview: could not load the fixture "
|
|
2363
|
+
f"manifest: {exc}", file=sys.stderr)
|
|
2364
|
+
return 2
|
|
2365
|
+
print(f"codereview: fixture scope: "
|
|
2366
|
+
f"{len(fixture_scope['patterns'])} path(s) from "
|
|
2367
|
+
f"{fixture_scope['manifest_path']} (policy "
|
|
2368
|
+
f"v{fixture_scope['policy_version']}, sha256 "
|
|
2369
|
+
f"{fixture_scope['manifest_sha256'][:12]}…) — "
|
|
2370
|
+
f"fixture findings will be classified separately, "
|
|
2371
|
+
f"not dropped", file=sys.stderr)
|
|
2372
|
+
precedents = None
|
|
2373
|
+
if args.precedents:
|
|
2374
|
+
try:
|
|
2375
|
+
precedents = _load_precedents(
|
|
2376
|
+
args.precedents, repo=args.repo or ".")
|
|
2377
|
+
except Exception as exc: # noqa: BLE001
|
|
2378
|
+
print(f"codereview: could not load the precedent "
|
|
2379
|
+
f"store: {exc}", file=sys.stderr)
|
|
2380
|
+
return 2
|
|
2381
|
+
print(f"codereview: precedent store: "
|
|
2382
|
+
f"{len(precedents['entries'])} entr(ies) from "
|
|
2383
|
+
f"{precedents['store_path']} (sha256 "
|
|
2384
|
+
f"{precedents['store_sha256'][:12]}…) — standing "
|
|
2385
|
+
f"findings will be classified separately, not "
|
|
2386
|
+
f"dropped", file=sys.stderr)
|
|
2387
|
+
project_policy = None
|
|
2388
|
+
policy_path = args.policy
|
|
2389
|
+
# P3-01: a policy DISCOVERED in the reviewed repo is
|
|
2390
|
+
# repo-supplied configuration. The origin is stamped on
|
|
2391
|
+
# the policy, lands in the record's policy section, and a
|
|
2392
|
+
# discovered policy warns on stderr. And since the owner's
|
|
2393
|
+
# 2026-11-06 ruling (option 1), a discovered policy is no
|
|
2394
|
+
# longer applied in full: unless the operator passes
|
|
2395
|
+
# --trust-repo-policy, the restriction in policy.py drops
|
|
2396
|
+
# its hooks and gate-weakening keys back to defaults HERE,
|
|
2397
|
+
# at load — before the hook runner is built from the
|
|
2398
|
+
# policy below and before the gate reads it — and names
|
|
2399
|
+
# every dropped key on the record (policy.ignored_keys).
|
|
2400
|
+
policy_origin = "explicit" if policy_path is not None \
|
|
2401
|
+
else None
|
|
2402
|
+
if policy_path is None:
|
|
2403
|
+
discovered = policy_mod.find_policy(args.repo or ".")
|
|
2404
|
+
if discovered:
|
|
2405
|
+
policy_path = discovered
|
|
2406
|
+
policy_origin = "discovered-in-repo"
|
|
2407
|
+
if policy_path:
|
|
2408
|
+
try:
|
|
2409
|
+
project_policy = policy_mod.load_policy(policy_path)
|
|
2410
|
+
except Exception as exc: # noqa: BLE001
|
|
2411
|
+
print(f"codereview: could not load the project "
|
|
2412
|
+
f"policy: {exc}", file=sys.stderr)
|
|
2413
|
+
return 2
|
|
2414
|
+
project_policy["origin"] = policy_origin
|
|
2415
|
+
ignored_keys = []
|
|
2416
|
+
if policy_origin == "discovered-in-repo" \
|
|
2417
|
+
and not args.trust_repo_policy:
|
|
2418
|
+
ignored_keys = policy_mod \
|
|
2419
|
+
.apply_discovered_restrictions(project_policy)
|
|
2420
|
+
print(f"codereview: project policy: "
|
|
2421
|
+
f"{len(project_policy['paths'])} path rule(s) from "
|
|
2422
|
+
f"{policy_path} — the gate enforces it and the "
|
|
2423
|
+
f"record lists every rule applied",
|
|
2424
|
+
file=sys.stderr)
|
|
2425
|
+
if policy_origin == "discovered-in-repo":
|
|
2426
|
+
print(f"codereview: WARNING: that policy was "
|
|
2427
|
+
f"discovered in the reviewed repository "
|
|
2428
|
+
f"({policy_path}) — it comes from the tree "
|
|
2429
|
+
f"under review, not from the operator; the "
|
|
2430
|
+
f"record's policy section states this origin",
|
|
2431
|
+
file=sys.stderr)
|
|
2432
|
+
if ignored_keys:
|
|
2433
|
+
print(f"codereview: WARNING: "
|
|
2434
|
+
f"{len(ignored_keys)} gate-weakening "
|
|
2435
|
+
f"key(s) in that policy were ignored "
|
|
2436
|
+
f"under the discovered-policy trust "
|
|
2437
|
+
f"restriction ({', '.join(ignored_keys)}) "
|
|
2438
|
+
f"— pass --trust-repo-policy to honour "
|
|
2439
|
+
f"the repository's policy in full",
|
|
2440
|
+
file=sys.stderr)
|
|
2441
|
+
# Epic L3: lifecycle hooks from the policy's [hooks] table.
|
|
2442
|
+
# The runner is created here (no side effects) and fired at
|
|
2443
|
+
# the lifecycle points below; with no [hooks] commands it
|
|
2444
|
+
# stays None and the run is exactly as before. Hooks run
|
|
2445
|
+
# sandboxed (Epic C) and can never change the verdict.
|
|
2446
|
+
hook_runner = None
|
|
2447
|
+
if project_policy is not None:
|
|
2448
|
+
_hooks_config = hooks_mod.hooks_from_policy(
|
|
2449
|
+
project_policy)
|
|
2450
|
+
if _hooks_config is not None:
|
|
2451
|
+
hook_runner = hooks_mod.HookRunner(
|
|
2452
|
+
_hooks_config, args.repo or ".")
|
|
2453
|
+
print(f"codereview: lifecycle hooks: "
|
|
2454
|
+
f"{', '.join(hook_runner.configured_events())} "
|
|
2455
|
+
f"configured in {policy_path} — sandboxed, "
|
|
2456
|
+
f"recorded in the record's hooks section, "
|
|
2457
|
+
f"never gate-changing", file=sys.stderr)
|
|
2458
|
+
if not patch.strip():
|
|
2459
|
+
print("codereview: the change is empty — nothing to review.",
|
|
2460
|
+
file=sys.stderr)
|
|
2461
|
+
return 2
|
|
2462
|
+
|
|
2463
|
+
# Epic K3a hosting state for this run (set in the specs
|
|
2464
|
+
# branch below): an active HerdrHost plus the config-marked
|
|
2465
|
+
# seat names, or the inert reason the record carries as one
|
|
2466
|
+
# `herdr_hosting` line. Override providers (bench, internal
|
|
2467
|
+
# runner) are never hosted.
|
|
2468
|
+
herdr_host = None
|
|
2469
|
+
herdr_seats = None
|
|
2470
|
+
herdr_inert = None
|
|
2471
|
+
if providers_override is not None:
|
|
2472
|
+
providers = providers_override
|
|
2473
|
+
else:
|
|
2474
|
+
specs = args.provider
|
|
2475
|
+
if not specs:
|
|
2476
|
+
env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
|
|
2477
|
+
try:
|
|
2478
|
+
specs = [s.strip() for s in env.split(",")
|
|
2479
|
+
if s.strip()] \
|
|
2480
|
+
or userconfig.default_provider_specs()
|
|
2481
|
+
except ValueError as exc:
|
|
2482
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2483
|
+
return 2
|
|
2484
|
+
if not specs and _stdin_is_tty():
|
|
2485
|
+
# First open in a terminal: run the setup wizard,
|
|
2486
|
+
# then use what the user just saved as their setup.
|
|
2487
|
+
# An aborted wizard (EOF / Ctrl-C) is a clean stop,
|
|
2488
|
+
# never a traceback: fall through to not-set-up.
|
|
2489
|
+
try:
|
|
2490
|
+
_run_setup_wizard()
|
|
2491
|
+
specs = userconfig.default_provider_specs()
|
|
2492
|
+
except (ValueError, EOFError,
|
|
2493
|
+
KeyboardInterrupt) as exc:
|
|
2494
|
+
print(f"codereview: setup did not complete "
|
|
2495
|
+
f"({exc}); nothing was run.",
|
|
2496
|
+
file=sys.stderr)
|
|
2497
|
+
return 2
|
|
2498
|
+
if not specs:
|
|
2499
|
+
print(userconfig.NOT_SET_UP, file=sys.stderr)
|
|
2500
|
+
return 2
|
|
2501
|
+
# Epic K3a: seats the saved config marks for herdr
|
|
2502
|
+
# hosting ([hosting] herdr = [...]) are detected ONCE
|
|
2503
|
+
# for the run. An active detection hosts those seats;
|
|
2504
|
+
# an inert one leaves every seat headless and puts ONE
|
|
2505
|
+
# line in the record (record_extra below) — herdr is
|
|
2506
|
+
# never a hard dependency.
|
|
2507
|
+
try:
|
|
2508
|
+
herdr_seats = userconfig.default_hosting_herdr_seats()
|
|
2509
|
+
except ValueError as exc:
|
|
2510
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2511
|
+
return 2
|
|
2512
|
+
if herdr_seats:
|
|
2513
|
+
plan = herdrhost.detect()
|
|
2514
|
+
if plan["active"]:
|
|
2515
|
+
herdr_host = plan["host"]
|
|
2516
|
+
else:
|
|
2517
|
+
herdr_inert = herdrhost.inert_line(plan)
|
|
2518
|
+
herdr_seats = None
|
|
2519
|
+
try:
|
|
2520
|
+
providers = build_providers(
|
|
2521
|
+
specs, agent_timeout=args.timeout,
|
|
2522
|
+
herdr_host=herdr_host, herdr_seats=herdr_seats)
|
|
2523
|
+
except Exception as exc: # noqa: BLE001
|
|
2524
|
+
print(f"codereview: provider setup failed: {exc}",
|
|
2525
|
+
file=sys.stderr)
|
|
2526
|
+
return 2
|
|
2527
|
+
|
|
2528
|
+
lens_seats, seat_roles, role_models, tech_lead_seat = {}, {}, {}, None
|
|
2529
|
+
try:
|
|
2530
|
+
_config_roles = userconfig.default_role_assignments()
|
|
2531
|
+
except ValueError as exc:
|
|
2532
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2533
|
+
return 2
|
|
2534
|
+
if args.role or _config_roles:
|
|
2535
|
+
try:
|
|
2536
|
+
_cli_roles = registry.parse_role_assignments(args.role)
|
|
2537
|
+
except ValueError as exc:
|
|
2538
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2539
|
+
return 2
|
|
2540
|
+
_merged_roles = {**_config_roles, **_cli_roles}
|
|
2541
|
+
try:
|
|
2542
|
+
lens_seats, seat_roles, role_models, tech_lead_seat = \
|
|
2543
|
+
_apply_role_assignments(
|
|
2544
|
+
providers,
|
|
2545
|
+
[f"{r}={spec}" for r, spec
|
|
2546
|
+
in _merged_roles.items()],
|
|
2547
|
+
agent_timeout=args.timeout,
|
|
2548
|
+
herdr_host=herdr_host,
|
|
2549
|
+
herdr_seats=herdr_seats)
|
|
2550
|
+
except Exception as exc: # noqa: BLE001
|
|
2551
|
+
print(f"codereview: role assignment failed: {exc}",
|
|
2552
|
+
file=sys.stderr)
|
|
2553
|
+
return 2
|
|
2554
|
+
|
|
2555
|
+
# Seat metadata precedence: the derived baseline, then
|
|
2556
|
+
# the saved setup's [seat_meta] (P6-06 — the families the
|
|
2557
|
+
# wizard computed for this panel), then explicit
|
|
2558
|
+
# --seat-meta, then the caller's override below.
|
|
2559
|
+
try:
|
|
2560
|
+
saved_meta = userconfig.default_seat_meta()
|
|
2561
|
+
except ValueError as exc:
|
|
2562
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2563
|
+
return 2
|
|
2564
|
+
baseline_meta = _default_seat_meta(providers)
|
|
2565
|
+
for _name, _family in saved_meta.items():
|
|
2566
|
+
if _name in baseline_meta and _family:
|
|
2567
|
+
baseline_meta[_name] = {
|
|
2568
|
+
"family": _family,
|
|
2569
|
+
"tier": baseline_meta[_name].get(
|
|
2570
|
+
"tier", "unknown")}
|
|
2571
|
+
try:
|
|
2572
|
+
seat_meta = _parse_seat_meta(
|
|
2573
|
+
args.seat_meta, baseline_meta)
|
|
2574
|
+
except ValueError as exc:
|
|
2575
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2576
|
+
return 2
|
|
2577
|
+
if seat_meta_override:
|
|
2578
|
+
seat_meta.update(seat_meta_override)
|
|
2579
|
+
violations = core.check_tier_policy(seat_meta,
|
|
2580
|
+
private=args.private)
|
|
2581
|
+
if violations:
|
|
2582
|
+
for violation in violations:
|
|
2583
|
+
print(f"codereview: tier policy: {violation}",
|
|
2584
|
+
file=sys.stderr)
|
|
2585
|
+
return 2
|
|
2586
|
+
|
|
2587
|
+
fallbacks = fallbacks_override
|
|
2588
|
+
if fallbacks is None and args.fallback:
|
|
2589
|
+
fallbacks = {}
|
|
2590
|
+
for spec in args.fallback:
|
|
2591
|
+
lens, eq, pspec = spec.partition("=")
|
|
2592
|
+
lens = lens.strip()
|
|
2593
|
+
if not eq or lens not in core.LENSES or not pspec.strip():
|
|
2594
|
+
print(f"codereview: bad --fallback {spec!r}: want "
|
|
2595
|
+
f"lens=provider-spec with lens one of "
|
|
2596
|
+
f"{sorted(core.LENSES)}", file=sys.stderr)
|
|
2597
|
+
return 2
|
|
2598
|
+
try:
|
|
2599
|
+
built = build_providers(
|
|
2600
|
+
[pspec.strip()], agent_timeout=args.timeout)
|
|
2601
|
+
except Exception as exc: # noqa: BLE001
|
|
2602
|
+
print(f"codereview: fallback setup failed: {exc}",
|
|
2603
|
+
file=sys.stderr)
|
|
2604
|
+
return 2
|
|
2605
|
+
(fb_name, fb_fn), = built.items()
|
|
2606
|
+
if fb_name in providers:
|
|
2607
|
+
print(f"codereview: fallback seat {fb_name!r} is "
|
|
2608
|
+
f"already a primary seat", file=sys.stderr)
|
|
2609
|
+
return 2
|
|
2610
|
+
fallbacks[lens] = (fb_name, fb_fn)
|
|
2611
|
+
|
|
2612
|
+
record_extra = None
|
|
2613
|
+
if args.range:
|
|
2614
|
+
record_extra = {"range": args.range}
|
|
2615
|
+
try:
|
|
2616
|
+
record_extra["head"] = core._git(
|
|
2617
|
+
args.repo or ".", "rev-parse", "HEAD")
|
|
2618
|
+
except Exception: # noqa: BLE001 — head is best-effort
|
|
2619
|
+
pass
|
|
2620
|
+
if seat_roles:
|
|
2621
|
+
# Which model served which role is part of the record
|
|
2622
|
+
# (owner directive 2026-11-04): seat name, provider,
|
|
2623
|
+
# model, and family for every assigned role.
|
|
2624
|
+
record_extra = {**(record_extra or {}),
|
|
2625
|
+
"seat_roles": seat_roles,
|
|
2626
|
+
"role_models": role_models}
|
|
2627
|
+
if herdr_inert:
|
|
2628
|
+
# Epic K3a: hosting was configured but the adapter
|
|
2629
|
+
# stayed inert — exactly one line in the record says
|
|
2630
|
+
# why; the seats all ran headless.
|
|
2631
|
+
record_extra = {**(record_extra or {}),
|
|
2632
|
+
"herdr_hosting": herdr_inert}
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
cwd = args.repo or "."
|
|
2636
|
+
test_results = _run_tests(args.test_command, cwd)
|
|
2637
|
+
|
|
2638
|
+
review_kwargs = {}
|
|
2639
|
+
if not args.no_probe_harness:
|
|
2640
|
+
# Probe harness (refutation-process solution §5.2):
|
|
2641
|
+
# the Tech Lead's probe channel, wired by default so a
|
|
2642
|
+
# needs_probe decision no longer auto-escalates for
|
|
2643
|
+
# lack of a channel. Probes execute in the harness
|
|
2644
|
+
# sandbox against a temp copy of the reviewed tree —
|
|
2645
|
+
# never in the tree itself, never with the process
|
|
2646
|
+
# environment. The swarm internal runner reaches this
|
|
2647
|
+
# same wiring through cli.main.
|
|
2648
|
+
from . import probe as probe_mod
|
|
2649
|
+
review_kwargs["probe_fn"] = probe_mod.make_probe_fn(
|
|
2650
|
+
args.repo or ".", timeout=args.probe_timeout)
|
|
2651
|
+
if lens_seats:
|
|
2652
|
+
review_kwargs["lens_seats"] = lens_seats
|
|
2653
|
+
if tech_lead_seat and "tech_lead" in \
|
|
2654
|
+
inspect.signature(core.run_review).parameters:
|
|
2655
|
+
# Tech Lead designation seam: when the engine carries the
|
|
2656
|
+
# adjudication role (step4 design), hand the designated
|
|
2657
|
+
# seat over — the engine itself enforces the independence
|
|
2658
|
+
# rule (its family must differ from raiser and refuter)
|
|
2659
|
+
# and substitutes loudly when the designee is ineligible.
|
|
2660
|
+
# Until the seam lands, the assignment travels in the
|
|
2661
|
+
# record's seat_roles only.
|
|
2662
|
+
review_kwargs["tech_lead"] = tech_lead_seat
|
|
2663
|
+
if args.tech_lead:
|
|
2664
|
+
# The explicit --tech-lead flag designates the same role a
|
|
2665
|
+
# --role/saved-setup assignment can; the command line wins
|
|
2666
|
+
# (the same precedence --role has over the saved setup).
|
|
2667
|
+
review_kwargs["tech_lead"] = args.tech_lead
|
|
2668
|
+
if args.evidence_config:
|
|
2669
|
+
# Epic C: run the evidence config against a sandboxed
|
|
2670
|
+
# copy of the tree and attach the record to the review;
|
|
2671
|
+
# run_review stores it in the report and applies the C3
|
|
2672
|
+
# gate rule. Verification rounds keep their own gate
|
|
2673
|
+
# semantics and do not take evidence (review_kwargs only
|
|
2674
|
+
# reaches run_review).
|
|
2675
|
+
try:
|
|
2676
|
+
evidence_cfg = evidence_mod.load_config(
|
|
2677
|
+
args.evidence_config)
|
|
2678
|
+
except ValueError as exc:
|
|
2679
|
+
print(f"codereview: {exc}", file=sys.stderr)
|
|
2680
|
+
return 2
|
|
2681
|
+
review_kwargs["evidence"] = evidence_mod.collect(
|
|
2682
|
+
evidence_cfg, cwd)
|
|
2683
|
+
shadow_role_seat = seat_roles.get("tech-lead-shadow")
|
|
2684
|
+
if args.tech_lead_shadow or shadow_role_seat or os.environ.get(
|
|
2685
|
+
"CODEREVIEW_TECH_LEAD_SHADOW") == "1":
|
|
2686
|
+
# Shadow Tech Lead: recorded, non-binding, gate-neutral
|
|
2687
|
+
# (the engine guarantees verdict invariance). Seat
|
|
2688
|
+
# precedence: an explicit tech-lead-shadow role, then
|
|
2689
|
+
# the binding --tech-lead designation, then the engine's
|
|
2690
|
+
# own independent-seat choice.
|
|
2691
|
+
review_kwargs["tech_lead_shadow"] = True
|
|
2692
|
+
shadow_seat = shadow_role_seat or args.tech_lead \
|
|
2693
|
+
or tech_lead_seat
|
|
2694
|
+
if shadow_seat:
|
|
2695
|
+
review_kwargs["tech_lead_shadow_seat"] = shadow_seat
|
|
2696
|
+
|
|
2697
|
+
file_context = None
|
|
2698
|
+
if not args.verify_previous and not args.patch \
|
|
2699
|
+
and not args.files and not args.no_file_context:
|
|
2700
|
+
# Acceptance D1: attach the post-change contents of the
|
|
2701
|
+
# changed files so reviewers can check definitions the
|
|
2702
|
+
# hunks reference but do not contain. A collection
|
|
2703
|
+
# problem degrades to the old diff-only behaviour with
|
|
2704
|
+
# the reason on stderr — context is an aid, never a
|
|
2705
|
+
# reason to fail a review.
|
|
2706
|
+
try:
|
|
2707
|
+
file_context, fc_entries = core.collect_file_context(
|
|
2708
|
+
args.repo or ".", patch, range_spec=args.range,
|
|
2709
|
+
staged=args.staged)
|
|
2710
|
+
except Exception as exc: # noqa: BLE001
|
|
2711
|
+
print(f"codereview: file context unavailable "
|
|
2712
|
+
f"({exc}); reviewing the diff alone",
|
|
2713
|
+
file=sys.stderr)
|
|
2714
|
+
file_context = None
|
|
2715
|
+
if file_context is not None:
|
|
2716
|
+
attached = [e for e in fc_entries
|
|
2717
|
+
if e.get("sent_chars")]
|
|
2718
|
+
skipped = [e for e in fc_entries if e.get("skipped")]
|
|
2719
|
+
notes = []
|
|
2720
|
+
for e in fc_entries:
|
|
2721
|
+
if e.get("truncated"):
|
|
2722
|
+
notes.append(
|
|
2723
|
+
f"{e['file']} truncated "
|
|
2724
|
+
f"({e['truncated']})")
|
|
2725
|
+
print(f"codereview: file context: "
|
|
2726
|
+
f"{len(attached)} file(s) attached "
|
|
2727
|
+
f"({sum(e['sent_chars'] for e in attached)} "
|
|
2728
|
+
f"chars), {len(skipped)} skipped"
|
|
2729
|
+
+ (f"; {'; '.join(notes)}" if notes else ""),
|
|
2730
|
+
file=sys.stderr)
|
|
2731
|
+
if not file_context:
|
|
2732
|
+
file_context = None
|
|
2733
|
+
|
|
2734
|
+
agents_context = None
|
|
2735
|
+
if not args.verify_previous and not args.patch \
|
|
2736
|
+
and not args.files and not args.no_agents_context:
|
|
2737
|
+
# Epic L1: ingest the repository's AGENTS.md guidance
|
|
2738
|
+
# (root + nested along the changed paths) as advisory
|
|
2739
|
+
# reviewer context. The files are untrusted: the
|
|
2740
|
+
# collector redacts them, run_review delimits them as
|
|
2741
|
+
# data in the prompt, and their provenance lands in the
|
|
2742
|
+
# record — they never touch the policy, the panel, or
|
|
2743
|
+
# the gate. A collection problem degrades to no guidance
|
|
2744
|
+
# with the reason on stderr, exactly like file context.
|
|
2745
|
+
try:
|
|
2746
|
+
agents_context, ag_entries = \
|
|
2747
|
+
agentsmd.collect_agents_context(
|
|
2748
|
+
args.repo or ".", patch)
|
|
2749
|
+
except Exception as exc: # noqa: BLE001
|
|
2750
|
+
print(f"codereview: AGENTS.md context unavailable "
|
|
2751
|
+
f"({exc}); reviewing without project guidance",
|
|
2752
|
+
file=sys.stderr)
|
|
2753
|
+
agents_context = None
|
|
2754
|
+
if agents_context is not None:
|
|
2755
|
+
skipped = [e for e in ag_entries if e.get("skipped")]
|
|
2756
|
+
print(f"codereview: AGENTS.md context: "
|
|
2757
|
+
f"{len(agents_context['files'])} file(s) "
|
|
2758
|
+
f"ingested ({agents_context['total_chars']} "
|
|
2759
|
+
f"chars), {len(skipped)} skipped"
|
|
2760
|
+
+ ("; truncation recorded"
|
|
2761
|
+
if agents_context["truncated"] else ""),
|
|
2762
|
+
file=sys.stderr)
|
|
2763
|
+
|
|
2764
|
+
# Epic L4: attention notifications resolve from the USER
|
|
2765
|
+
# SETUP's [notify] table only — never the project policy
|
|
2766
|
+
# (notifications are operator preference, not project
|
|
2767
|
+
# contract). Unset means no Notifier at all, and the run
|
|
2768
|
+
# behaves exactly as before this epic.
|
|
2769
|
+
notify = notify_mod.notifier_for(
|
|
2770
|
+
userconfig.default_notify_command())
|
|
2771
|
+
|
|
2772
|
+
if hook_runner is not None:
|
|
2773
|
+
# Epic L3 pre-run: every setup step has succeeded and
|
|
2774
|
+
# the run is committed — the next thing that happens is
|
|
2775
|
+
# the round's first seat call.
|
|
2776
|
+
hook_runner.fire("pre-run")
|
|
2777
|
+
|
|
2778
|
+
if args.verify_previous:
|
|
2779
|
+
try:
|
|
2780
|
+
with open(args.verify_previous, encoding="utf-8") as fh:
|
|
2781
|
+
previous = json.load(fh)
|
|
2782
|
+
except Exception as exc: # noqa: BLE001
|
|
2783
|
+
print(f"codereview: could not read previous report: {exc}",
|
|
2784
|
+
file=sys.stderr)
|
|
2785
|
+
return 2
|
|
2786
|
+
builder_response = _maybe_file(args.builder_response)
|
|
2787
|
+
report = core.run_verification(
|
|
2788
|
+
patch, providers, previous,
|
|
2789
|
+
builder_response,
|
|
2790
|
+
test_results=test_results,
|
|
2791
|
+
max_patch_chars=args.max_patch_chars,
|
|
2792
|
+
allow_major=args.allow_major, strict=args.strict,
|
|
2793
|
+
seat_meta=seat_meta, fallbacks=fallbacks,
|
|
2794
|
+
record_extra=record_extra,
|
|
2795
|
+
private=args.private, progress=_seat_progress,
|
|
2796
|
+
notify=notify)
|
|
2797
|
+
# Epic C4: verify the builder report's claimed commands
|
|
2798
|
+
# against the tree and record the outcome in the run
|
|
2799
|
+
# record. This runs after the gate has been decided and
|
|
2800
|
+
# nothing reads the section back — claim verification
|
|
2801
|
+
# is evidence, not a gate input: a mismatch downgrades
|
|
2802
|
+
# the claim, never the verdict (claims.BOUNDARY).
|
|
2803
|
+
claim_section = claims_mod.verify_report_claims(
|
|
2804
|
+
builder_response, args.repo or ".")
|
|
2805
|
+
if claim_section is not None:
|
|
2806
|
+
report["claim_verification"] = claim_section
|
|
2807
|
+
else:
|
|
2808
|
+
report = core.run_review(
|
|
2809
|
+
patch, providers, context=_maybe_file(args.context),
|
|
2810
|
+
test_results=test_results,
|
|
2811
|
+
max_patch_chars=args.max_patch_chars,
|
|
2812
|
+
allow_major=args.allow_major, strict=args.strict,
|
|
2813
|
+
seat_meta=seat_meta, fallbacks=fallbacks,
|
|
2814
|
+
record_extra=record_extra,
|
|
2815
|
+
fixture_scope=fixture_scope, private=args.private,
|
|
2816
|
+
precedents=precedents, policy=project_policy,
|
|
2817
|
+
gate_profile=args.gate_profile,
|
|
2818
|
+
file_context=file_context, progress=_seat_progress,
|
|
2819
|
+
agents_context=agents_context, notify=notify,
|
|
2820
|
+
tech_lead_timeout=args.timeout, **review_kwargs)
|
|
2821
|
+
|
|
2822
|
+
if policy_origin == "discovered-in-repo" \
|
|
2823
|
+
and not args.verify_previous and not args.range:
|
|
2824
|
+
# P3-01(c), working-tree half: `git diff` never shows
|
|
2825
|
+
# an untracked or modified .codereview.toml as a patch
|
|
2826
|
+
# section, so run_review's section-based
|
|
2827
|
+
# policy_self_modification flag cannot see the common
|
|
2828
|
+
# case — the applied policy file is itself part of the
|
|
2829
|
+
# uncommitted change. Ask git about the file directly.
|
|
2830
|
+
try:
|
|
2831
|
+
_pol_status = core._git(
|
|
2832
|
+
args.repo or ".", "status", "--porcelain",
|
|
2833
|
+
"--", policy_mod.POLICY_FILENAME)
|
|
2834
|
+
except Exception: # noqa: BLE001 — the flag is
|
|
2835
|
+
# best-effort observability, never a run failure.
|
|
2836
|
+
_pol_status = ""
|
|
2837
|
+
if _pol_status.strip():
|
|
2838
|
+
_touched = report.setdefault(
|
|
2839
|
+
"policy_self_modification", [])
|
|
2840
|
+
if policy_mod.POLICY_FILENAME not in _touched:
|
|
2841
|
+
report["policy_self_modification"] = sorted(
|
|
2842
|
+
_touched + [policy_mod.POLICY_FILENAME])
|
|
2843
|
+
|
|
2844
|
+
if hook_runner is not None:
|
|
2845
|
+
# Epic L3: the blocked trigger is a fact of the finished
|
|
2846
|
+
# round; the record is written WITH the hooks section
|
|
2847
|
+
# (configured events, trigger, pre-run result) so the
|
|
2848
|
+
# post-record hooks are handed a file that already
|
|
2849
|
+
# carries all of it.
|
|
2850
|
+
hook_runner.blocked_trigger = \
|
|
2851
|
+
hooks_mod.first_blocked_trigger(
|
|
2852
|
+
report.get("reviewer_reports"))
|
|
2853
|
+
report["hooks"] = hook_runner.section()
|
|
2854
|
+
try:
|
|
2855
|
+
jpath, mpath = _write_report(report, args.out, "codereview")
|
|
2856
|
+
except _ReportWriteFailed:
|
|
2857
|
+
return 2
|
|
2858
|
+
if hook_runner is not None:
|
|
2859
|
+
# The record now exists: on-blocked (only when a seat
|
|
2860
|
+
# blocked) and post-verdict fire with its path. Their
|
|
2861
|
+
# results are appended to the hooks section and the
|
|
2862
|
+
# record is rewritten once at the same path, so the
|
|
2863
|
+
# file the hooks received is the file carrying the
|
|
2864
|
+
# complete hooks ledger.
|
|
2865
|
+
if hook_runner.blocked_trigger is not None:
|
|
2866
|
+
hook_runner.fire("on-blocked", record_path=jpath)
|
|
2867
|
+
hook_runner.fire("post-verdict", record_path=jpath)
|
|
2868
|
+
report["hooks"] = hook_runner.section()
|
|
2869
|
+
try:
|
|
2870
|
+
jpath, mpath = _write_report(
|
|
2871
|
+
report, args.out, "codereview",
|
|
2872
|
+
stem=os.path.basename(jpath)[:-len(".json")])
|
|
2873
|
+
except _ReportWriteFailed:
|
|
2874
|
+
return 2
|
|
2875
|
+
if args.attempts_log:
|
|
2876
|
+
try:
|
|
2877
|
+
entry = {
|
|
2878
|
+
"ts": core._now_iso(), "mode": report.get("mode"),
|
|
2879
|
+
"gate": report.get("gate"),
|
|
2880
|
+
"degraded": report.get("degraded"),
|
|
2881
|
+
"range": args.range,
|
|
2882
|
+
"patch_sha256": report.get("patch_sha256"),
|
|
2883
|
+
"seats": [{
|
|
2884
|
+
"provider": r.get("provider"),
|
|
2885
|
+
"lens": r.get("lens"),
|
|
2886
|
+
"status": r.get("status"),
|
|
2887
|
+
"family": r.get("family"),
|
|
2888
|
+
"tier": r.get("tier"),
|
|
2889
|
+
"fallback_for": r.get("fallback_for"),
|
|
2890
|
+
"attempts": r.get("attempts"),
|
|
2891
|
+
"error_class": (r.get("error_classes")
|
|
2892
|
+
or [""])[-1],
|
|
2893
|
+
} for r in report.get("reviewer_reports") or []],
|
|
2894
|
+
"tests_ok": (all(t.get("ok") for t in test_results)
|
|
2895
|
+
if test_results else None),
|
|
2896
|
+
}
|
|
2897
|
+
with open(args.attempts_log, "a",
|
|
2898
|
+
encoding="utf-8") as fh:
|
|
2899
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
2900
|
+
except Exception: # noqa: BLE001 — bookkeeping never breaks
|
|
2901
|
+
pass
|
|
2902
|
+
n_block = sum(1 for f in report.get("findings") or []
|
|
2903
|
+
if f["severity"] in ("blocker", "major"))
|
|
2904
|
+
print(f"codereview: gate = {report['gate'].upper()} "
|
|
2905
|
+
f"({'; '.join(report.get('gate_reasons') or [])})")
|
|
2906
|
+
print(f" findings: {len(report.get('findings') or [])} "
|
|
2907
|
+
f"({n_block} blocking-severity)")
|
|
2908
|
+
open_disputes = report.get("open_disputes") or {}
|
|
2909
|
+
if open_disputes.get("count"):
|
|
2910
|
+
print(f" open disputes: {open_disputes['count']} "
|
|
2911
|
+
f"({', '.join(open_disputes.get('ids') or [])}) "
|
|
2912
|
+
f"— see `codereview disputes`")
|
|
2913
|
+
print(f" report: {mpath}")
|
|
2914
|
+
print(f" data: {jpath}")
|
|
2915
|
+
return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
|
|
2916
|
+
report["gate"], 2)
|
|
2917
|
+
|
|
2918
|
+
|
|
2919
|
+
def console_main():
|
|
2920
|
+
"""The installed `codereview` / `reviewloop` entry point: the
|
|
2921
|
+
CLI under the crash policy (crash.run_guarded). `main` itself
|
|
2922
|
+
is unchanged so library callers and tests still see exceptions."""
|
|
2923
|
+
return crash_mod.run_guarded(main)
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
if __name__ == "__main__":
|
|
2927
|
+
raise SystemExit(console_main())
|