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
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""Verdict record schema v1.0.0 — the one versioned record contract.
|
|
2
|
+
|
|
3
|
+
Every surface (CLI, MCP server, GitHub adapter, agent seats,
|
|
4
|
+
workflow/loop driver) calls the same core (`core.run_review` /
|
|
5
|
+
`core.run_verification`) and therefore emits the same record.
|
|
6
|
+
This module is that record's formal contract:
|
|
7
|
+
|
|
8
|
+
- `RECORD_SCHEMA` — a JSON-Schema-style definition (a plain
|
|
9
|
+
dict; the product is stdlib-only, so no external schema
|
|
10
|
+
library is involved) describing what the core ACTUALLY emits.
|
|
11
|
+
- `validate_record(record)` — the in-tree validator. The core
|
|
12
|
+
validates every record at emission (`finalize_record` raises
|
|
13
|
+
on a violation), and `core.write_report` re-validates a v1
|
|
14
|
+
record before it is written to disk, so a record that reaches
|
|
15
|
+
disk — the copy the MCP `get_verdict` tool reads back — is a
|
|
16
|
+
record that validated.
|
|
17
|
+
- `finalize_record(report, ...)` — the core's last step before
|
|
18
|
+
a report is returned: it stamps the v1 sections below onto
|
|
19
|
+
the legacy record shape and validates the result. It never
|
|
20
|
+
touches the gate, the findings, or any verdict logic — this
|
|
21
|
+
is record-shape work only.
|
|
22
|
+
|
|
23
|
+
Relationship to the legacy tag: records have always carried
|
|
24
|
+
`schema: "codereview.report/1"`. That tag is KEPT (consumers
|
|
25
|
+
compare it literally) and v1.0.0 is carried alongside it as
|
|
26
|
+
`schema_version`. A record with the tag but no
|
|
27
|
+
`schema_version` is a legacy (pre-v1) record: readers must not
|
|
28
|
+
treat it as corrupt, but it makes no v1 promises — in
|
|
29
|
+
particular its verify-mode records may lack `coverage` and
|
|
30
|
+
`degraded_causes`, and no record of that era carries `checks`,
|
|
31
|
+
`panel`, `cost`, or `record_identity` sections.
|
|
32
|
+
|
|
33
|
+
Semantics locked by this schema (the GTM engineering contract):
|
|
34
|
+
|
|
35
|
+
- Verdicts are PASS / BLOCKED / INCONCLUSIVE. The record's
|
|
36
|
+
legacy `gate` field spells them lowercase ("pass" /
|
|
37
|
+
"blocked" / "inconclusive"); the v1 `verdict` field carries
|
|
38
|
+
the uppercase spelling, and the two must agree. CLI exit
|
|
39
|
+
codes are 0 (PASS), 1 (BLOCKED), 2 (INCONCLUSIVE) — see
|
|
40
|
+
`EXIT_CODES`.
|
|
41
|
+
- A PASS means: no actionable issue was found under the
|
|
42
|
+
checks that ran. It is NOT a correctness guarantee. The
|
|
43
|
+
record states this in its `verdict_semantics` section.
|
|
44
|
+
- Checks that did not run must be visible: the `checks`
|
|
45
|
+
section lists `run`, `not_run`, and `unavailable` (attempted
|
|
46
|
+
but produced no result — e.g. every reviewer seat errored)
|
|
47
|
+
separately. A PASS over a partial check set is visible as
|
|
48
|
+
such, never dressed up as a full one.
|
|
49
|
+
- Degraded-provider behaviour is explicit: `degraded` +
|
|
50
|
+
`degraded_causes` on the record, per-seat status/family/tier
|
|
51
|
+
in `panel.seats`, and a degraded panel can never be read as
|
|
52
|
+
a healthy one (see core's gate rules, unchanged by this
|
|
53
|
+
schema).
|
|
54
|
+
- Cost: the core has NO cost data — providers return review
|
|
55
|
+
text only and report no usage to the core. The v1 record
|
|
56
|
+
says so explicitly (`cost.status == "not_available"`)
|
|
57
|
+
instead of omitting the field or inventing a number. (The
|
|
58
|
+
bench wrapper estimates cost per attempt from its own seat
|
|
59
|
+
price config, outside this record, under the separate
|
|
60
|
+
`codereview.benchresult/1` schema.)
|
|
61
|
+
- Record identity/path: `record_identity.id` is a content
|
|
62
|
+
hash stamped at emission. The record's PATH is assigned by
|
|
63
|
+
the writer, not the core, so at emission `record_identity
|
|
64
|
+
.path` is null; `core.write_report` stamps the file's base
|
|
65
|
+
name into the written copy, and the MCP payload carries the
|
|
66
|
+
full path as `record_path`, outside the record.
|
|
67
|
+
- Record content integrity (P6-03): the writer also stamps
|
|
68
|
+
`record_identity.content_sha256` — a hash over the WHOLE
|
|
69
|
+
record except the `record_identity` envelope itself (the
|
|
70
|
+
id is a hash of a subset; the path is location metadata,
|
|
71
|
+
so a renamed file still verifies). record-verify
|
|
72
|
+
recomputes it in single and chain mode: ANY later edit —
|
|
73
|
+
a finding's status, the gate, forged test results — fails
|
|
74
|
+
verification. Threat model, stated plainly: this detects
|
|
75
|
+
edits and corruption by anything that does not recompute
|
|
76
|
+
the hash (hand edits, torn copies, accidental mutation).
|
|
77
|
+
It is NOT a signature: an editor who recomputes the hash
|
|
78
|
+
over their edited record passes. Records written before
|
|
79
|
+
the hash existed carry none and verify under the legacy
|
|
80
|
+
rule — the check reports not-applicable, exactly like the
|
|
81
|
+
coverage/tier/sidecar checks treat their pre-machinery
|
|
82
|
+
records — because registered and pilot records are
|
|
83
|
+
evidence and must not become unverifiable retroactively.
|
|
84
|
+
The legacy rule is bounded by provenance (P6-12): it
|
|
85
|
+
applies only when the record's own declarations
|
|
86
|
+
establish that it predates the stamp (see
|
|
87
|
+
`content_hash_required`). The first bound trusted
|
|
88
|
+
whatever a single edited field declared, and read
|
|
89
|
+
missing or unparseable fields as pre-stamp — deleting
|
|
90
|
+
or garbling two fields reopened the downgrade (Phase 6
|
|
91
|
+
rerun 2). The rule now fails closed: EVERY declaration
|
|
92
|
+
(the top-level version, `provenance.generator_version`,
|
|
93
|
+
`created_at`) is read; a stamping-era or unparseable
|
|
94
|
+
declaration defeats the exemption, and no usable
|
|
95
|
+
declarations at all defeats it — legacy requires
|
|
96
|
+
positive, consistent, pre-stamp evidence. What remains
|
|
97
|
+
is the stated limit above: recompute the hash, or
|
|
98
|
+
rewrite every declaration into one consistent pre-stamp
|
|
99
|
+
claim — the hash was never a signature. One documented
|
|
100
|
+
cost: unstamped records written by 0.13.0 dev builds in
|
|
101
|
+
the hours before the stamp landed (stamping-era version
|
|
102
|
+
line, pre-stamp `created_at`) no longer read as legacy;
|
|
103
|
+
that window's artefacts are dev records, not registered
|
|
104
|
+
evidence — the B2-era records predate the 0.13.0 version
|
|
105
|
+
line entirely and keep the exemption. Records with no
|
|
106
|
+
trace of the v1 machinery at all (no `schema_version`,
|
|
107
|
+
`provenance` or `record_identity`) are read by the same
|
|
108
|
+
rule on their surviving declarations (P6-12b, rerun 3):
|
|
109
|
+
the three-key deletion from a current record no longer
|
|
110
|
+
reaches a categorical exemption — a surviving
|
|
111
|
+
stamping-era `version` defeats it, and no declarations
|
|
112
|
+
at all establishes nothing. Genuine pre-schema records
|
|
113
|
+
carry a pre-stamp `version` and `created_at` and keep
|
|
114
|
+
the exemption on that evidence.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
import datetime
|
|
118
|
+
import hashlib
|
|
119
|
+
import json
|
|
120
|
+
import re
|
|
121
|
+
|
|
122
|
+
SCHEMA_VERSION = "1.0.0"
|
|
123
|
+
SCHEMA_ID = "codereview.report/1"
|
|
124
|
+
|
|
125
|
+
# The verdict vocabulary. `gate` keeps the historical lowercase
|
|
126
|
+
# spelling; `verdict` is the same fact in the contract's
|
|
127
|
+
# uppercase spelling.
|
|
128
|
+
GATES = ("pass", "blocked", "inconclusive")
|
|
129
|
+
VERDICTS = ("PASS", "BLOCKED", "INCONCLUSIVE")
|
|
130
|
+
VERDICT_FOR_GATE = dict(zip(GATES, VERDICTS))
|
|
131
|
+
EXIT_CODES = {"pass": 0, "blocked": 1, "inconclusive": 2}
|
|
132
|
+
|
|
133
|
+
MODES = ("review", "verify")
|
|
134
|
+
SEVERITIES = ("blocker", "major", "minor", "nit")
|
|
135
|
+
EVIDENCE_CLASSES = ("E0", "E1", "E2", "E3")
|
|
136
|
+
|
|
137
|
+
PASS_MEANS = ("no actionable issue found under the checks "
|
|
138
|
+
"that ran (see the checks section)")
|
|
139
|
+
PASS_IS_NOT = ("not a correctness guarantee: checks that did "
|
|
140
|
+
"not run are listed in checks.not_run / "
|
|
141
|
+
"checks.unavailable, and a degraded panel is "
|
|
142
|
+
"labelled in panel / degraded_causes")
|
|
143
|
+
|
|
144
|
+
# JSON-Schema-style definition of the v1 record. It describes
|
|
145
|
+
# the emitted shape: `required` is the v1 core contract, and
|
|
146
|
+
# `additionalProperties` is deliberately true — the record has
|
|
147
|
+
# always grown optional, mode- and feature-specific sections
|
|
148
|
+
# (policy, tiering, evidence, disputes, hooks, ...) and a v1
|
|
149
|
+
# reader must tolerate sections it does not know.
|
|
150
|
+
RECORD_SCHEMA = {
|
|
151
|
+
"$id": "codereview.report/1",
|
|
152
|
+
"version": SCHEMA_VERSION,
|
|
153
|
+
"title": "CodeReview verdict record",
|
|
154
|
+
"type": "object",
|
|
155
|
+
"additionalProperties": True,
|
|
156
|
+
"required": [
|
|
157
|
+
"schema", "schema_version", "version", "mode",
|
|
158
|
+
"created_at", "gate", "verdict", "gate_reasons",
|
|
159
|
+
"findings", "reviewer_reports", "degraded",
|
|
160
|
+
"degraded_causes", "truncated", "scope", "checks",
|
|
161
|
+
"panel", "cost", "duration_ms", "waivers",
|
|
162
|
+
"provenance", "record_identity", "verdict_semantics",
|
|
163
|
+
],
|
|
164
|
+
"properties": {
|
|
165
|
+
"schema": {"const": SCHEMA_ID},
|
|
166
|
+
"schema_version": {"const": SCHEMA_VERSION},
|
|
167
|
+
"version": {"type": "string",
|
|
168
|
+
"description": "product version that "
|
|
169
|
+
"produced the record"},
|
|
170
|
+
"mode": {"enum": list(MODES)},
|
|
171
|
+
"created_at": {"type": "string",
|
|
172
|
+
"description": "ISO-8601 creation time"},
|
|
173
|
+
"gate": {"enum": list(GATES),
|
|
174
|
+
"description": "legacy lowercase verdict"},
|
|
175
|
+
"verdict": {"enum": list(VERDICTS),
|
|
176
|
+
"description": "uppercase verdict; must "
|
|
177
|
+
"agree with gate"},
|
|
178
|
+
"gate_reasons": {"type": "array",
|
|
179
|
+
"items": {"type": "string"}},
|
|
180
|
+
"findings": {
|
|
181
|
+
"type": "array",
|
|
182
|
+
"description": "merged findings standing at the "
|
|
183
|
+
"gate (review mode); in verify mode, "
|
|
184
|
+
"NEW findings raised by the round — "
|
|
185
|
+
"prior blockers live in cleared / "
|
|
186
|
+
"remaining / unjudged",
|
|
187
|
+
"items": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"required": ["id", "severity", "title"],
|
|
190
|
+
"properties": {
|
|
191
|
+
"id": {"type": "string"},
|
|
192
|
+
"severity": {"enum": list(SEVERITIES)},
|
|
193
|
+
"category": {"type": ["string", "null"]},
|
|
194
|
+
"file": {"type": ["string", "null"]},
|
|
195
|
+
"line": {"type": ["integer", "null"]},
|
|
196
|
+
"title": {"type": "string"},
|
|
197
|
+
"evidence": {"type": ["string", "null"]},
|
|
198
|
+
"evidence_quote": {
|
|
199
|
+
"type": ["string", "null"]},
|
|
200
|
+
"evidence_class": {
|
|
201
|
+
"enum": list(EVIDENCE_CLASSES),
|
|
202
|
+
"description": "E0 deterministic, E2 "
|
|
203
|
+
"probe-confirmed, E1 "
|
|
204
|
+
"corroborated, E3 "
|
|
205
|
+
"single-source / "
|
|
206
|
+
"uncorroborated. Absent "
|
|
207
|
+
"means unstamped (legacy "
|
|
208
|
+
"or deterministic-era "
|
|
209
|
+
"finding), never E0."},
|
|
210
|
+
"recommendation": {
|
|
211
|
+
"type": ["string", "null"]},
|
|
212
|
+
"confidence": {
|
|
213
|
+
"type": ["number", "null"]},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
"reviewer_reports": {
|
|
218
|
+
"type": "array",
|
|
219
|
+
"description": "one entry per reviewer job that "
|
|
220
|
+
"ran (per seat/lens/batch in review "
|
|
221
|
+
"mode; per seat in verify mode)",
|
|
222
|
+
"items": {
|
|
223
|
+
"type": "object",
|
|
224
|
+
"required": ["provider", "status"],
|
|
225
|
+
"properties": {
|
|
226
|
+
"provider": {"type": ["string", "null"]},
|
|
227
|
+
"lens": {"type": ["string", "null"]},
|
|
228
|
+
"status": {"enum": ["ok", "error"]},
|
|
229
|
+
"family": {"type": ["string", "null"]},
|
|
230
|
+
"tier": {"type": ["string", "null"]},
|
|
231
|
+
"duration_ms": {
|
|
232
|
+
"type": ["integer", "null"]},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
"degraded": {"type": "boolean"},
|
|
237
|
+
"degraded_causes": {"type": "array",
|
|
238
|
+
"items": {"type": "string"}},
|
|
239
|
+
"truncated": {"type": "boolean"},
|
|
240
|
+
"patch_sha256": {"type": "string",
|
|
241
|
+
"description": "sha256 of the patch "
|
|
242
|
+
"text reviewed"},
|
|
243
|
+
"scope": {
|
|
244
|
+
"type": "object",
|
|
245
|
+
"required": ["files", "patch_sha256"],
|
|
246
|
+
"properties": {
|
|
247
|
+
"files": {"type": "array",
|
|
248
|
+
"items": {"type": "string"}},
|
|
249
|
+
"patch_sha256": {"type": "string"},
|
|
250
|
+
"patch_chars": {"type": ["integer", "null"]},
|
|
251
|
+
"range": {"type": ["string", "null"],
|
|
252
|
+
"description": "git range, when the "
|
|
253
|
+
"caller recorded one "
|
|
254
|
+
"(CLI runs); null for "
|
|
255
|
+
"direct core callers"},
|
|
256
|
+
"head": {"type": ["string", "null"],
|
|
257
|
+
"description": "commit the range was "
|
|
258
|
+
"taken at, same "
|
|
259
|
+
"provenance as range"},
|
|
260
|
+
"previous_gate": {"type": ["string", "null"],
|
|
261
|
+
"description": "verify mode: "
|
|
262
|
+
"the prior "
|
|
263
|
+
"record's gate"},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
"checks": {
|
|
267
|
+
"type": "object",
|
|
268
|
+
"required": ["run", "not_run", "unavailable"],
|
|
269
|
+
"description": "check availability, explicit: a "
|
|
270
|
+
"check is in exactly one list. "
|
|
271
|
+
"Names: deterministic_scan, "
|
|
272
|
+
"reviewer_panel, tests, evidence, "
|
|
273
|
+
"policy, coverage (review mode "
|
|
274
|
+
"only; verify mode lists coverage "
|
|
275
|
+
"as not_run — a known, documented "
|
|
276
|
+
"gap: the verification round does "
|
|
277
|
+
"not recompute per-file coverage)",
|
|
278
|
+
"properties": {
|
|
279
|
+
"run": {"type": "array",
|
|
280
|
+
"items": {"type": "string"}},
|
|
281
|
+
"not_run": {"type": "array",
|
|
282
|
+
"items": {"type": "string"}},
|
|
283
|
+
"unavailable": {"type": "array",
|
|
284
|
+
"items": {"type": "string"}},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
"panel": {
|
|
288
|
+
"type": "object",
|
|
289
|
+
"required": ["seats", "degraded", "degraded_causes"],
|
|
290
|
+
"properties": {
|
|
291
|
+
"seats": {
|
|
292
|
+
"type": "array",
|
|
293
|
+
"description": "distinct seats by "
|
|
294
|
+
"(provider, lens), first "
|
|
295
|
+
"report wins for status",
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "object",
|
|
298
|
+
"properties": {
|
|
299
|
+
"provider": {
|
|
300
|
+
"type": ["string", "null"]},
|
|
301
|
+
"lens": {
|
|
302
|
+
"type": ["string", "null"]},
|
|
303
|
+
"family": {
|
|
304
|
+
"type": ["string", "null"]},
|
|
305
|
+
"tier": {
|
|
306
|
+
"type": ["string", "null"]},
|
|
307
|
+
"status": {
|
|
308
|
+
"type": ["string", "null"]},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
"families": {"type": "array",
|
|
313
|
+
"items": {"type": "string"}},
|
|
314
|
+
"seat_count": {"type": "integer"},
|
|
315
|
+
"ok_count": {"type": "integer"},
|
|
316
|
+
"degraded": {"type": "boolean"},
|
|
317
|
+
"degraded_causes": {"type": "array",
|
|
318
|
+
"items": {"type": "string"}},
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
"cost": {
|
|
322
|
+
"type": "object",
|
|
323
|
+
"required": ["status"],
|
|
324
|
+
"properties": {
|
|
325
|
+
"status": {"const": "not_available"},
|
|
326
|
+
"amount_usd": {"type": "null"},
|
|
327
|
+
"reason": {"type": "string"},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
"duration_ms": {"type": ["integer", "null"],
|
|
331
|
+
"description": "core wall-clock for "
|
|
332
|
+
"the round; null only "
|
|
333
|
+
"when timing was not "
|
|
334
|
+
"recorded"},
|
|
335
|
+
"waivers": {
|
|
336
|
+
"type": "array",
|
|
337
|
+
"description": "waivers the caller recorded for "
|
|
338
|
+
"this round (review mode: the "
|
|
339
|
+
"policy-evaluation waivers, verbatim;"
|
|
340
|
+
" verify mode: always empty — "
|
|
341
|
+
"verification waivers live in the "
|
|
342
|
+
"loop/workflow state, documented "
|
|
343
|
+
"gap, not hidden)",
|
|
344
|
+
"items": {"type": "object"},
|
|
345
|
+
},
|
|
346
|
+
"provenance": {
|
|
347
|
+
"type": "object",
|
|
348
|
+
"required": ["generator", "generator_version",
|
|
349
|
+
"patch_sha256"],
|
|
350
|
+
"properties": {
|
|
351
|
+
"generator": {"const": "codereview"},
|
|
352
|
+
"generator_version": {"type": "string"},
|
|
353
|
+
"patch_sha256": {"type": "string"},
|
|
354
|
+
"range": {"type": ["string", "null"]},
|
|
355
|
+
"head": {"type": ["string", "null"]},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
"record_identity": {
|
|
359
|
+
"type": "object",
|
|
360
|
+
"required": ["id", "path"],
|
|
361
|
+
"properties": {
|
|
362
|
+
"id": {"type": "string",
|
|
363
|
+
"description": "sha256 over mode + "
|
|
364
|
+
"created_at + patch hash "
|
|
365
|
+
"+ gate, computed at "
|
|
366
|
+
"emission"},
|
|
367
|
+
"path": {"type": ["string", "null"],
|
|
368
|
+
"description": "null at emission; "
|
|
369
|
+
"write_report stamps "
|
|
370
|
+
"the written file's "
|
|
371
|
+
"base name"},
|
|
372
|
+
"content_sha256": {
|
|
373
|
+
"type": "string",
|
|
374
|
+
"description": "P6-03: sha256 over the "
|
|
375
|
+
"whole record except the "
|
|
376
|
+
"record_identity envelope, "
|
|
377
|
+
"stamped by write_report; "
|
|
378
|
+
"record-verify recomputes "
|
|
379
|
+
"it. Detects edits, is not "
|
|
380
|
+
"a signature. Absent on "
|
|
381
|
+
"records written before it "
|
|
382
|
+
"existed (legacy)"},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
"verdict_semantics": {
|
|
386
|
+
"type": "object",
|
|
387
|
+
"required": ["pass_means", "pass_is_not"],
|
|
388
|
+
"properties": {
|
|
389
|
+
"pass_means": {"const": PASS_MEANS},
|
|
390
|
+
"pass_is_not": {"const": PASS_IS_NOT},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _check_names(report):
|
|
398
|
+
"""Derive the checks section from the finished report.
|
|
399
|
+
|
|
400
|
+
Every name lands in exactly one list. A reviewer panel that
|
|
401
|
+
was configured but whose every job errored is `unavailable`
|
|
402
|
+
(attempted, no result) — not `run`, and not silently
|
|
403
|
+
absent. No panel at all is `not_run`."""
|
|
404
|
+
mode = report.get("mode")
|
|
405
|
+
run, not_run, unavailable = [], [], []
|
|
406
|
+
|
|
407
|
+
def place(name, bucket):
|
|
408
|
+
bucket.append(name)
|
|
409
|
+
|
|
410
|
+
# Deterministic scan: run_review always scans; the
|
|
411
|
+
# verification round re-checks prior deterministic findings
|
|
412
|
+
# against the new scan by construction.
|
|
413
|
+
place("deterministic_scan", run)
|
|
414
|
+
reports = report.get("reviewer_reports") or []
|
|
415
|
+
if not reports:
|
|
416
|
+
place("reviewer_panel", not_run)
|
|
417
|
+
elif any(r.get("status") == "ok" for r in reports
|
|
418
|
+
if isinstance(r, dict)):
|
|
419
|
+
place("reviewer_panel", run)
|
|
420
|
+
else:
|
|
421
|
+
place("reviewer_panel", unavailable)
|
|
422
|
+
place("tests", run if report.get("test_results") else not_run)
|
|
423
|
+
if mode == "review":
|
|
424
|
+
place("evidence", run if report.get("evidence") is not None
|
|
425
|
+
else not_run)
|
|
426
|
+
place("policy", run if report.get("policy") is not None
|
|
427
|
+
else not_run)
|
|
428
|
+
place("coverage", run if report.get("coverage") is not None
|
|
429
|
+
else not_run)
|
|
430
|
+
else: # verify
|
|
431
|
+
# The verify round collects no evidence record, evaluates
|
|
432
|
+
# no policy, and recomputes no coverage — visible here,
|
|
433
|
+
# not papered over (see RECORD_SCHEMA.checks).
|
|
434
|
+
for name in ("evidence", "policy", "coverage"):
|
|
435
|
+
place(name, not_run)
|
|
436
|
+
return {"run": run, "not_run": not_run,
|
|
437
|
+
"unavailable": unavailable}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _panel_section(report):
|
|
441
|
+
seats, seen = [], set()
|
|
442
|
+
for rep in report.get("reviewer_reports") or []:
|
|
443
|
+
if not isinstance(rep, dict):
|
|
444
|
+
continue
|
|
445
|
+
key = (rep.get("provider"), rep.get("lens"))
|
|
446
|
+
if key in seen:
|
|
447
|
+
continue
|
|
448
|
+
seen.add(key)
|
|
449
|
+
seats.append({
|
|
450
|
+
"provider": rep.get("provider"),
|
|
451
|
+
"lens": rep.get("lens"),
|
|
452
|
+
"family": rep.get("family"),
|
|
453
|
+
"tier": rep.get("tier"),
|
|
454
|
+
"status": rep.get("status"),
|
|
455
|
+
})
|
|
456
|
+
# Canonical order: reviewer_reports arrive in completion
|
|
457
|
+
# order (a thread artifact); the panel section is a summary
|
|
458
|
+
# of composition, so it sorts by (provider, lens) and two
|
|
459
|
+
# records of equivalent runs carry identical panel sections.
|
|
460
|
+
seats.sort(key=lambda s: (str(s["provider"]),
|
|
461
|
+
str(s["lens"])))
|
|
462
|
+
families = sorted({s["family"] for s in seats
|
|
463
|
+
if s["family"] not in (None, "", "unknown")})
|
|
464
|
+
return {
|
|
465
|
+
"seats": seats,
|
|
466
|
+
"families": families,
|
|
467
|
+
"seat_count": len(seats),
|
|
468
|
+
"ok_count": sum(1 for s in seats if s["status"] == "ok"),
|
|
469
|
+
"degraded": bool(report.get("degraded")),
|
|
470
|
+
"degraded_causes": list(report.get("degraded_causes") or []),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def record_content_sha256(record):
|
|
475
|
+
"""The P6-03 content hash: sha256 of the record's
|
|
476
|
+
canonical JSON (sorted keys, compact separators) with the
|
|
477
|
+
`record_identity` envelope excluded. Excluding the whole
|
|
478
|
+
envelope is what lets the writer stamp the hash INTO the
|
|
479
|
+
envelope without self-reference, and keeps the hash a
|
|
480
|
+
statement about the record's content rather than its
|
|
481
|
+
file name."""
|
|
482
|
+
body = {k: v for k, v in record.items()
|
|
483
|
+
if k != "record_identity"}
|
|
484
|
+
canon = json.dumps(body, ensure_ascii=False,
|
|
485
|
+
sort_keys=True, separators=(",", ":"))
|
|
486
|
+
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# The stamping era (P6-12): the first generator line whose
|
|
490
|
+
# writer stamps content_sha256 on every record it writes,
|
|
491
|
+
# and the moment that stamp landed (the P6-03 commit,
|
|
492
|
+
# 2026-11-07T02:07:56Z). The two bounds classify each of a
|
|
493
|
+
# record's provenance declarations as pre-stamp or
|
|
494
|
+
# stamping-era; `content_hash_required` reads them all
|
|
495
|
+
# and fails closed (see its docstring for the rule and
|
|
496
|
+
# the rerun-2 history).
|
|
497
|
+
CONTENT_HASH_ERA_VERSION = (0, 13, 0)
|
|
498
|
+
CONTENT_HASH_ERA_FROM = datetime.datetime(
|
|
499
|
+
2026, 11, 7, 2, 7, 56, tzinfo=datetime.timezone.utc)
|
|
500
|
+
|
|
501
|
+
_VERSION_PREFIX_RE = re.compile(r"^(\d+(?:\.\d+)*)")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _version_tuple(text):
|
|
505
|
+
"""The leading numeric dotted prefix of a version
|
|
506
|
+
string as a tuple ("0.13.0.dev0" → (0, 13, 0)), or
|
|
507
|
+
None when the string carries no numeric prefix."""
|
|
508
|
+
if not isinstance(text, str):
|
|
509
|
+
return None
|
|
510
|
+
match = _VERSION_PREFIX_RE.match(text)
|
|
511
|
+
if not match:
|
|
512
|
+
return None
|
|
513
|
+
return tuple(int(p) for p in match.group(1).split("."))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _created_instant(text):
|
|
517
|
+
"""A record's created_at as an aware datetime, or None
|
|
518
|
+
when it does not parse (a naive value is read as UTC —
|
|
519
|
+
that is the only form the writer emits)."""
|
|
520
|
+
if not isinstance(text, str) or not text:
|
|
521
|
+
return None
|
|
522
|
+
try:
|
|
523
|
+
parsed = datetime.datetime.fromisoformat(
|
|
524
|
+
text.replace("Z", "+00:00"))
|
|
525
|
+
except ValueError:
|
|
526
|
+
return None
|
|
527
|
+
if parsed.tzinfo is None:
|
|
528
|
+
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
|
529
|
+
return parsed
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _version_state(value):
|
|
533
|
+
"""A version declaration classified: "absent" (None),
|
|
534
|
+
"garbled" (present but carries no numeric prefix),
|
|
535
|
+
"pre" (predates the stamping-era version line) or
|
|
536
|
+
"era" (the stamping-era line or later)."""
|
|
537
|
+
if value is None:
|
|
538
|
+
return "absent"
|
|
539
|
+
parsed = _version_tuple(value)
|
|
540
|
+
if parsed is None:
|
|
541
|
+
return "garbled"
|
|
542
|
+
return ("era" if parsed >= CONTENT_HASH_ERA_VERSION
|
|
543
|
+
else "pre")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _created_state(value):
|
|
547
|
+
"""A created_at declaration classified like
|
|
548
|
+
`_version_state`, against the stamp instant."""
|
|
549
|
+
if value is None:
|
|
550
|
+
return "absent"
|
|
551
|
+
parsed = _created_instant(value)
|
|
552
|
+
if parsed is None:
|
|
553
|
+
return "garbled"
|
|
554
|
+
return ("era" if parsed >= CONTENT_HASH_ERA_FROM
|
|
555
|
+
else "pre")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def content_hash_required(record):
|
|
559
|
+
"""True when a record carrying no
|
|
560
|
+
`record_identity.content_sha256` must NOT take the
|
|
561
|
+
legacy exemption — the hash was removed, or the claim
|
|
562
|
+
to have never been stamped does not hold up (P6-12).
|
|
563
|
+
|
|
564
|
+
The record's THREE provenance declarations are read:
|
|
565
|
+
the top-level `version`, `provenance.generator_version`
|
|
566
|
+
and `created_at`. Legacy is a positive-evidence rule
|
|
567
|
+
and fails closed:
|
|
568
|
+
|
|
569
|
+
- any declaration in the stamping era defeats it (a
|
|
570
|
+
single edited field cannot outvote the others);
|
|
571
|
+
- any declaration present but unparseable defeats it
|
|
572
|
+
(garbled provenance is not pre-stamp provenance);
|
|
573
|
+
- no usable declarations on a record of the
|
|
574
|
+
declarations era defeats it (absent provenance is
|
|
575
|
+
not pre-stamp provenance);
|
|
576
|
+
- legacy requires at least one parseable pre-stamp
|
|
577
|
+
declaration and no defeating one.
|
|
578
|
+
|
|
579
|
+
The rule applies inside the pre-machinery shape too
|
|
580
|
+
(P6-12b, rerun 3): a record carrying no trace of the
|
|
581
|
+
v1 machinery (no `schema_version`, no `provenance`,
|
|
582
|
+
no `record_identity`) is NOT categorically exempt —
|
|
583
|
+
its surviving declarations (`version`, `created_at`)
|
|
584
|
+
are read by the same rule. That closes the three-key
|
|
585
|
+
deletion: dropping the machinery sections from a
|
|
586
|
+
current record while keeping `version: 0.13.0.dev0`
|
|
587
|
+
(her G6) declares the stamping era and fails, and
|
|
588
|
+
deleting every declaration (her G1) establishes
|
|
589
|
+
nothing and fails. Genuine pre-schema records still
|
|
590
|
+
pass: every writer of that era stamped `version` and
|
|
591
|
+
`created_at` (the 0.9.4 / 0.9.6 historicals carry
|
|
592
|
+
both), so one parseable pre-stamp declaration is
|
|
593
|
+
positive evidence, not a new burden. A record with
|
|
594
|
+
no declarations at all was never written by any
|
|
595
|
+
version of the writer; the consistent-forgery
|
|
596
|
+
residual (forge every declaration pre-stamp, her
|
|
597
|
+
G2/G3/G4) remains the documented not-a-signature
|
|
598
|
+
limit, unchanged.
|
|
599
|
+
|
|
600
|
+
The first P6-12 bound read missing/unparseable fields
|
|
601
|
+
as pre-stamp and let any one edited declaration carry
|
|
602
|
+
the claim, so stripping the hash plus deleting or
|
|
603
|
+
garbling two fields reopened the downgrade (Phase 6
|
|
604
|
+
rerun 2, p6_downgrade2.py). Documented cost of failing
|
|
605
|
+
closed: unstamped records from 0.13.0 dev builds in
|
|
606
|
+
the hours before the stamp landed (stamping-era
|
|
607
|
+
version line, pre-stamp `created_at`) no longer read
|
|
608
|
+
as legacy — a closed set of dev artefacts; registered
|
|
609
|
+
and B2-era records predate the 0.13.0 line entirely.
|
|
610
|
+
"""
|
|
611
|
+
provenance = record.get("provenance")
|
|
612
|
+
generator_version = (provenance.get("generator_version")
|
|
613
|
+
if isinstance(provenance, dict)
|
|
614
|
+
else None)
|
|
615
|
+
states = [
|
|
616
|
+
_version_state(record.get("version")),
|
|
617
|
+
_version_state(generator_version),
|
|
618
|
+
_created_state(record.get("created_at")),
|
|
619
|
+
]
|
|
620
|
+
if "era" in states or "garbled" in states:
|
|
621
|
+
return True
|
|
622
|
+
return "pre" not in states
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _record_id(report):
|
|
626
|
+
material = json.dumps({
|
|
627
|
+
"mode": report.get("mode"),
|
|
628
|
+
"created_at": report.get("created_at"),
|
|
629
|
+
"patch_sha256": report.get("patch_sha256"),
|
|
630
|
+
"gate": report.get("gate"),
|
|
631
|
+
}, sort_keys=True, ensure_ascii=False)
|
|
632
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def finalize_record(report, duration_ms=None, waivers=None):
|
|
636
|
+
"""Stamp the v1.0.0 sections onto a finished core report
|
|
637
|
+
and validate it. Called by core.run_review /
|
|
638
|
+
core.run_verification as their last step (before verdict
|
|
639
|
+
notifications, which read only gate/findings). Raises
|
|
640
|
+
ValueError listing every problem when the finished record
|
|
641
|
+
does not meet the schema — an emission bug must fail loud,
|
|
642
|
+
never write a record that claims v1 and is not.
|
|
643
|
+
|
|
644
|
+
Idempotent in identity: a record that already carries a
|
|
645
|
+
record_identity.id keeps it (the CLI's hooks rewrite goes
|
|
646
|
+
through write_report, not back through here, but a caller
|
|
647
|
+
re-finalising a record must not mint a second identity)."""
|
|
648
|
+
gate = report.get("gate")
|
|
649
|
+
files = report.get("files")
|
|
650
|
+
if files is None:
|
|
651
|
+
# Verify mode carries no file list; the scope is the
|
|
652
|
+
# files of the patch it judged, which the caller of
|
|
653
|
+
# finalize cannot see — core passes them via the
|
|
654
|
+
# report's `_scope_files` scratch key when it has them.
|
|
655
|
+
files = report.pop("_scope_files", None) or []
|
|
656
|
+
else:
|
|
657
|
+
report.pop("_scope_files", None)
|
|
658
|
+
report["schema_version"] = SCHEMA_VERSION
|
|
659
|
+
report["verdict"] = VERDICT_FOR_GATE.get(gate)
|
|
660
|
+
report["scope"] = {
|
|
661
|
+
"files": list(files),
|
|
662
|
+
"patch_sha256": report.get("patch_sha256"),
|
|
663
|
+
"patch_chars": report.get("patch_chars"),
|
|
664
|
+
"range": report.get("range"),
|
|
665
|
+
"head": report.get("head"),
|
|
666
|
+
"previous_gate": report.get("previous_gate"),
|
|
667
|
+
}
|
|
668
|
+
report["checks"] = _check_names(report)
|
|
669
|
+
report["panel"] = _panel_section(report)
|
|
670
|
+
report["cost"] = {
|
|
671
|
+
"status": "not_available",
|
|
672
|
+
"amount_usd": None,
|
|
673
|
+
"currency": "USD",
|
|
674
|
+
"reason": ("core providers return review text only and "
|
|
675
|
+
"report no usage/cost to the core; bench "
|
|
676
|
+
"wrappers may estimate cost outside this "
|
|
677
|
+
"record (codereview.benchresult/1 cost_usd)"),
|
|
678
|
+
}
|
|
679
|
+
report["duration_ms"] = duration_ms
|
|
680
|
+
normalised = []
|
|
681
|
+
for waiver in waivers or []:
|
|
682
|
+
normalised.append(waiver if isinstance(waiver, dict)
|
|
683
|
+
else {"finding_id": str(waiver)})
|
|
684
|
+
report["waivers"] = normalised
|
|
685
|
+
report["provenance"] = {
|
|
686
|
+
"generator": "codereview",
|
|
687
|
+
"generator_version": report.get("version"),
|
|
688
|
+
"patch_sha256": report.get("patch_sha256"),
|
|
689
|
+
"range": report.get("range"),
|
|
690
|
+
"head": report.get("head"),
|
|
691
|
+
}
|
|
692
|
+
existing = report.get("record_identity") or {}
|
|
693
|
+
report["record_identity"] = {
|
|
694
|
+
"id": existing.get("id") or _record_id(report),
|
|
695
|
+
"path": existing.get("path"),
|
|
696
|
+
}
|
|
697
|
+
report["verdict_semantics"] = {
|
|
698
|
+
"pass_means": PASS_MEANS,
|
|
699
|
+
"pass_is_not": PASS_IS_NOT,
|
|
700
|
+
}
|
|
701
|
+
problems = validate_record(report)
|
|
702
|
+
if problems:
|
|
703
|
+
raise ValueError(
|
|
704
|
+
"verdict record failed schema v" + SCHEMA_VERSION
|
|
705
|
+
+ " validation at emission: " + "; ".join(problems))
|
|
706
|
+
return report
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _is_int(value):
|
|
710
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def validate_record(record):
|
|
714
|
+
"""Validate a record against RECORD_SCHEMA (v1.0.0).
|
|
715
|
+
|
|
716
|
+
Returns a list of problem strings — empty means valid.
|
|
717
|
+
This is a hand-rolled validator for the subset of
|
|
718
|
+
JSON-Schema the definition uses (types, required, enum,
|
|
719
|
+
const), stdlib only. It validates the v1 contract, not
|
|
720
|
+
every optional section the record may carry: unknown
|
|
721
|
+
sections are permitted (additionalProperties) and their
|
|
722
|
+
own sub-schemas (evidence, tiering, precedents, ...) keep
|
|
723
|
+
their own validators."""
|
|
724
|
+
problems = []
|
|
725
|
+
|
|
726
|
+
def bad(msg):
|
|
727
|
+
problems.append(msg)
|
|
728
|
+
|
|
729
|
+
if not isinstance(record, dict):
|
|
730
|
+
return ["record is not an object"]
|
|
731
|
+
if record.get("schema") != SCHEMA_ID:
|
|
732
|
+
bad(f"schema is {record.get('schema')!r}, "
|
|
733
|
+
f"expected {SCHEMA_ID!r}")
|
|
734
|
+
if record.get("schema_version") != SCHEMA_VERSION:
|
|
735
|
+
bad(f"schema_version is {record.get('schema_version')!r}, "
|
|
736
|
+
f"expected {SCHEMA_VERSION!r} (a record without it is "
|
|
737
|
+
f"a legacy pre-v1 record, not a v1 record)")
|
|
738
|
+
if not isinstance(record.get("version"), str) \
|
|
739
|
+
or not record.get("version"):
|
|
740
|
+
bad("version must be a non-empty string")
|
|
741
|
+
mode = record.get("mode")
|
|
742
|
+
if mode not in MODES:
|
|
743
|
+
bad(f"mode is {mode!r}, expected one of {MODES} "
|
|
744
|
+
f"(the loop summary record is a container of verdict "
|
|
745
|
+
f"records, not itself a v1 verdict record)")
|
|
746
|
+
if not isinstance(record.get("created_at"), str) \
|
|
747
|
+
or not record.get("created_at"):
|
|
748
|
+
bad("created_at must be a non-empty string")
|
|
749
|
+
gate = record.get("gate")
|
|
750
|
+
if gate not in GATES:
|
|
751
|
+
bad(f"gate is {gate!r}, expected one of {GATES}")
|
|
752
|
+
elif record.get("verdict") != VERDICT_FOR_GATE[gate]:
|
|
753
|
+
bad(f"verdict is {record.get('verdict')!r} but gate "
|
|
754
|
+
f"{gate!r} requires {VERDICT_FOR_GATE[gate]!r}")
|
|
755
|
+
reasons = record.get("gate_reasons")
|
|
756
|
+
if not isinstance(reasons, list) \
|
|
757
|
+
or not all(isinstance(r, str) for r in reasons or []):
|
|
758
|
+
bad("gate_reasons must be a list of strings")
|
|
759
|
+
if not isinstance(record.get("degraded"), bool):
|
|
760
|
+
bad("degraded must be a boolean")
|
|
761
|
+
causes = record.get("degraded_causes")
|
|
762
|
+
if not isinstance(causes, list) \
|
|
763
|
+
or not all(isinstance(c, str) for c in causes or []):
|
|
764
|
+
bad("degraded_causes must be a list of strings "
|
|
765
|
+
"(verify-mode records before v1 omitted it; v1 "
|
|
766
|
+
"requires it, empty when not degraded)")
|
|
767
|
+
if record.get("degraded") and isinstance(causes, list) \
|
|
768
|
+
and not causes:
|
|
769
|
+
bad("degraded is true but degraded_causes is empty: "
|
|
770
|
+
"degraded behaviour must name its cause(s)")
|
|
771
|
+
if not isinstance(record.get("truncated"), bool):
|
|
772
|
+
bad("truncated must be a boolean")
|
|
773
|
+
findings = record.get("findings")
|
|
774
|
+
if not isinstance(findings, list):
|
|
775
|
+
bad("findings must be a list")
|
|
776
|
+
else:
|
|
777
|
+
for i, finding in enumerate(findings):
|
|
778
|
+
if not isinstance(finding, dict):
|
|
779
|
+
bad(f"findings[{i}] is not an object")
|
|
780
|
+
continue
|
|
781
|
+
if not finding.get("id"):
|
|
782
|
+
bad(f"findings[{i}] has no id")
|
|
783
|
+
if finding.get("severity") not in SEVERITIES:
|
|
784
|
+
bad(f"findings[{i}] severity is "
|
|
785
|
+
f"{finding.get('severity')!r}, expected one "
|
|
786
|
+
f"of {SEVERITIES}")
|
|
787
|
+
if not isinstance(finding.get("title"), str):
|
|
788
|
+
bad(f"findings[{i}] title must be a string")
|
|
789
|
+
eclass = finding.get("evidence_class")
|
|
790
|
+
if eclass is not None and eclass not in EVIDENCE_CLASSES:
|
|
791
|
+
bad(f"findings[{i}] evidence_class is "
|
|
792
|
+
f"{eclass!r}, expected one of "
|
|
793
|
+
f"{EVIDENCE_CLASSES} or absent (unstamped)")
|
|
794
|
+
reports = record.get("reviewer_reports")
|
|
795
|
+
if not isinstance(reports, list):
|
|
796
|
+
bad("reviewer_reports must be a list")
|
|
797
|
+
else:
|
|
798
|
+
for i, rep in enumerate(reports):
|
|
799
|
+
if not isinstance(rep, dict):
|
|
800
|
+
bad(f"reviewer_reports[{i}] is not an object")
|
|
801
|
+
continue
|
|
802
|
+
if "provider" not in rep:
|
|
803
|
+
bad(f"reviewer_reports[{i}] has no provider")
|
|
804
|
+
if rep.get("status") not in ("ok", "error"):
|
|
805
|
+
bad(f"reviewer_reports[{i}] status is "
|
|
806
|
+
f"{rep.get('status')!r}, expected 'ok' or "
|
|
807
|
+
f"'error'")
|
|
808
|
+
scope = record.get("scope")
|
|
809
|
+
if not isinstance(scope, dict):
|
|
810
|
+
bad("scope must be an object")
|
|
811
|
+
else:
|
|
812
|
+
if not isinstance(scope.get("files"), list):
|
|
813
|
+
bad("scope.files must be a list")
|
|
814
|
+
if scope.get("patch_sha256") != record.get("patch_sha256"):
|
|
815
|
+
bad("scope.patch_sha256 does not match the record's "
|
|
816
|
+
"patch_sha256")
|
|
817
|
+
if not (isinstance(record.get("patch_sha256"), str)
|
|
818
|
+
and len(record.get("patch_sha256") or "") == 64):
|
|
819
|
+
bad("patch_sha256 must be a 64-char sha256 hex string")
|
|
820
|
+
checks = record.get("checks")
|
|
821
|
+
if not isinstance(checks, dict):
|
|
822
|
+
bad("checks must be an object")
|
|
823
|
+
else:
|
|
824
|
+
lists = {}
|
|
825
|
+
for key in ("run", "not_run", "unavailable"):
|
|
826
|
+
value = checks.get(key)
|
|
827
|
+
if not isinstance(value, list) \
|
|
828
|
+
or not all(isinstance(c, str) for c in value or []):
|
|
829
|
+
bad(f"checks.{key} must be a list of check names")
|
|
830
|
+
# A non-list value is reported above and must not
|
|
831
|
+
# crash the validator (set(5) is a TypeError).
|
|
832
|
+
lists[key] = set(value) if isinstance(value, list) \
|
|
833
|
+
else set()
|
|
834
|
+
seen = lists["run"] | lists["not_run"] | lists["unavailable"]
|
|
835
|
+
total = sum(len(lists[k]) for k in lists)
|
|
836
|
+
if len(seen) != total:
|
|
837
|
+
bad("checks lists overlap: every check must be in "
|
|
838
|
+
"exactly one of run / not_run / unavailable")
|
|
839
|
+
if mode == "review" and gate == "pass" \
|
|
840
|
+
and "reviewer_panel" in lists.get("unavailable", set()):
|
|
841
|
+
bad("gate is pass but checks.unavailable contains "
|
|
842
|
+
"reviewer_panel: a PASS may not rest on a panel "
|
|
843
|
+
"that produced no result")
|
|
844
|
+
panel = record.get("panel")
|
|
845
|
+
if not isinstance(panel, dict):
|
|
846
|
+
bad("panel must be an object")
|
|
847
|
+
else:
|
|
848
|
+
if not isinstance(panel.get("seats"), list):
|
|
849
|
+
bad("panel.seats must be a list")
|
|
850
|
+
if panel.get("degraded") != record.get("degraded"):
|
|
851
|
+
bad("panel.degraded does not match the record's "
|
|
852
|
+
"degraded flag")
|
|
853
|
+
cost = record.get("cost")
|
|
854
|
+
if not isinstance(cost, dict) \
|
|
855
|
+
or cost.get("status") != "not_available":
|
|
856
|
+
bad("cost must be an object with status "
|
|
857
|
+
"'not_available' (the core has no cost data; a "
|
|
858
|
+
"record claiming a core cost figure is inventing "
|
|
859
|
+
"one)")
|
|
860
|
+
elif cost.get("amount_usd") is not None:
|
|
861
|
+
bad("cost.amount_usd must be null when cost.status is "
|
|
862
|
+
"not_available")
|
|
863
|
+
duration = record.get("duration_ms")
|
|
864
|
+
if duration is not None and not _is_int(duration):
|
|
865
|
+
bad("duration_ms must be an integer or null")
|
|
866
|
+
waivers = record.get("waivers")
|
|
867
|
+
if not isinstance(waivers, list) \
|
|
868
|
+
or not all(isinstance(w, dict) for w in waivers or []):
|
|
869
|
+
bad("waivers must be a list of waiver records (objects)")
|
|
870
|
+
provenance = record.get("provenance")
|
|
871
|
+
if not isinstance(provenance, dict):
|
|
872
|
+
bad("provenance must be an object")
|
|
873
|
+
else:
|
|
874
|
+
if provenance.get("generator") != "codereview":
|
|
875
|
+
bad("provenance.generator must be 'codereview'")
|
|
876
|
+
if provenance.get("patch_sha256") \
|
|
877
|
+
!= record.get("patch_sha256"):
|
|
878
|
+
bad("provenance.patch_sha256 does not match the "
|
|
879
|
+
"record's patch_sha256")
|
|
880
|
+
identity = record.get("record_identity")
|
|
881
|
+
if not isinstance(identity, dict):
|
|
882
|
+
bad("record_identity must be an object")
|
|
883
|
+
else:
|
|
884
|
+
if not (isinstance(identity.get("id"), str)
|
|
885
|
+
and len(identity.get("id") or "") == 64):
|
|
886
|
+
bad("record_identity.id must be a 64-char sha256 "
|
|
887
|
+
"hex string")
|
|
888
|
+
if identity.get("path") is not None \
|
|
889
|
+
and not isinstance(identity.get("path"), str):
|
|
890
|
+
bad("record_identity.path must be a string or null")
|
|
891
|
+
chash = identity.get("content_sha256")
|
|
892
|
+
if chash is not None and not (
|
|
893
|
+
isinstance(chash, str) and len(chash) == 64
|
|
894
|
+
and all(c in "0123456789abcdef"
|
|
895
|
+
for c in chash)):
|
|
896
|
+
bad("record_identity.content_sha256 must be a "
|
|
897
|
+
"64-char sha256 hex string when present")
|
|
898
|
+
semantics = record.get("verdict_semantics")
|
|
899
|
+
if not isinstance(semantics, dict) \
|
|
900
|
+
or semantics.get("pass_means") != PASS_MEANS \
|
|
901
|
+
or semantics.get("pass_is_not") != PASS_IS_NOT:
|
|
902
|
+
bad("verdict_semantics must carry the locked "
|
|
903
|
+
"pass_means / pass_is_not wording")
|
|
904
|
+
return problems
|