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/disputes.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview disputes — the computed open-dispute inventory,
|
|
3
|
+
the append-only disposition store, the release-flow freshness
|
|
4
|
+
check, the deterministic authority-affecting tagger, and quote
|
|
5
|
+
anchoring (`locate`).
|
|
6
|
+
|
|
7
|
+
Process tooling from the open-refutations solution §5
|
|
8
|
+
(owner-adopted 2026-11-04) and the step4 four-disputed expert
|
|
9
|
+
solution (disposition types, §4 reason fields):
|
|
10
|
+
|
|
11
|
+
- §5.1: the inventory is COMPUTED from the records, never
|
|
12
|
+
reconstructed by hand. `codereview disputes` scans every
|
|
13
|
+
review-*/ record for findings in a human-decision state
|
|
14
|
+
(status `disputed`, `refutation_weak`, or `escalated` —
|
|
15
|
+
the Tech Lead routed it to the owner, who has not yet
|
|
16
|
+
ruled; P6-05) with no entry in the disposition store.
|
|
17
|
+
- §5.2: dispositions live in an append-only store,
|
|
18
|
+
`dispositions.jsonl` at the repo root, keyed by finding
|
|
19
|
+
fingerprint. A disposition is never edited or deleted; a
|
|
20
|
+
later decision that replaces one is an OVERRIDE entry that
|
|
21
|
+
references the entry it supersedes. `record-verify` consults
|
|
22
|
+
the store: a disputed finding with an entry counts as
|
|
23
|
+
dispositioned; without one it does not.
|
|
24
|
+
- §5.3: `codereview disputes --check --span A..B` is the
|
|
25
|
+
release-flow freshness gate: a span may not ship carrying a
|
|
26
|
+
dispute that is neither dispositioned nor inventoried.
|
|
27
|
+
- §5.5: "protected class" becomes a deterministic tag —
|
|
28
|
+
AUTHORITY-AFFECTING: the defect could change who decides
|
|
29
|
+
(role/seat assignment), whether independence holds (family
|
|
30
|
+
attribution), whether a precedent or decision is valid
|
|
31
|
+
(precedent keys, decision cache), whether eligibility passes
|
|
32
|
+
(coverage, quorum, finder confirmation, record-verify), or
|
|
33
|
+
what leaves the machine (redaction, tier policy, --private,
|
|
34
|
+
public records). Report rendering and prompt display
|
|
35
|
+
truncation are explicitly NOT authority-affecting. The tag
|
|
36
|
+
decides who is FINAL (the owner), never who does the work.
|
|
37
|
+
- §5.6 + step4 solution §4: findings are anchored by their
|
|
38
|
+
validated quote, not by a bare line number; `line_basis`
|
|
39
|
+
records the numbering basis (diff-relative vs blob at the
|
|
40
|
+
reviewed commit) and `codereview locate` resolves a quote to
|
|
41
|
+
its blob positions at the reviewed commit. Disposition
|
|
42
|
+
entries carry the reason fields — `reason_check`,
|
|
43
|
+
`reason_corrected`, `basis_evidence` — so a right conclusion
|
|
44
|
+
carried by a wrong reason is data, not a new state.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import datetime
|
|
50
|
+
import hashlib
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import re
|
|
54
|
+
import subprocess
|
|
55
|
+
|
|
56
|
+
from . import core
|
|
57
|
+
|
|
58
|
+
DISPOSITIONS_SCHEMA = "codereview.dispositions/1"
|
|
59
|
+
STORE_FILENAME = "dispositions.jsonl"
|
|
60
|
+
|
|
61
|
+
DISPOSITION_TYPES = (
|
|
62
|
+
"sustain_refutation", "sustain_finding", "split", "fixed",
|
|
63
|
+
"retired_archived_subject", "retired_out_of_scope",
|
|
64
|
+
"superseded", "downgraded")
|
|
65
|
+
DECIDERS = ("owner", "tech_lead", "probe")
|
|
66
|
+
REASON_CHECKS = ("verified", "unverified", "contradicted")
|
|
67
|
+
MARKERS = ("ratified", "override")
|
|
68
|
+
OPEN_STATUSES = ("disputed", "refutation_weak", "escalated")
|
|
69
|
+
|
|
70
|
+
_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
71
|
+
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
72
|
+
_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{7,64}$")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _now_iso():
|
|
76
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
77
|
+
timespec="seconds")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------- authority tagger (§5.5)
|
|
81
|
+
|
|
82
|
+
DIM_WHO_DECIDES = "who decides"
|
|
83
|
+
DIM_INDEPENDENCE = "independence"
|
|
84
|
+
DIM_VALIDITY = "precedent/decision validity"
|
|
85
|
+
DIM_ELIGIBILITY = "eligibility"
|
|
86
|
+
DIM_EGRESS = "what leaves the machine"
|
|
87
|
+
|
|
88
|
+
# Keyword rules over the finding's title + category + file.
|
|
89
|
+
# Each rule is (dimension, [regex, ...]); matching is on the
|
|
90
|
+
# lowercased text. The sets are deliberately narrow: a tag that
|
|
91
|
+
# fires on everything recreates the owner-as-judge problem the
|
|
92
|
+
# definition exists to avoid (§5.5, risks table).
|
|
93
|
+
_KEYWORD_RULES = (
|
|
94
|
+
(DIM_WHO_DECIDES, (
|
|
95
|
+
r"tech lead", r"adjudicat", r"\broles?\b", r"\bseats?\b",
|
|
96
|
+
r"seat[-_ ]meta", r"lens[-_ ]seat")),
|
|
97
|
+
(DIM_INDEPENDENCE, (
|
|
98
|
+
r"independen", r"\bfamil\w*", r"cross[- ]family")),
|
|
99
|
+
(DIM_VALIDITY, (
|
|
100
|
+
r"precedent", r"decision cache", r"cache key",
|
|
101
|
+
r"fingerprint")),
|
|
102
|
+
(DIM_ELIGIBILITY, (
|
|
103
|
+
r"coverage", r"quorum", r"finder confirmation",
|
|
104
|
+
r"record[-_ ]verify", r"verify[-_ ]chain", r"eligib",
|
|
105
|
+
r"gate (logic|integrity)", r"\bbatch\w*")),
|
|
106
|
+
(DIM_EGRESS, (
|
|
107
|
+
r"redact", r"\btier\b", r"private", r"\bleaks?\b",
|
|
108
|
+
r"public record", r"\bsecret\b")),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Path rules over the finding's cited file: the two artifact
|
|
112
|
+
# classes whose every dispute is authority-affecting by nature
|
|
113
|
+
# (the precedent store itself, and the published attempts
|
|
114
|
+
# ledger). Broad source paths (core.py, cli.py, providers.py)
|
|
115
|
+
# are NOT path rules — the keyword rules decide there, so a
|
|
116
|
+
# provider-construction finding is not tagged merely for living
|
|
117
|
+
# in providers.py.
|
|
118
|
+
_PATH_RULES = (
|
|
119
|
+
(DIM_VALIDITY, (r"(^|/)precedents\.json$",)),
|
|
120
|
+
(DIM_EGRESS, (r"gate-attempts",)),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Exclusions (§5.5): report rendering and prompt display
|
|
124
|
+
# truncation are not authority-affecting. An exclusion applies
|
|
125
|
+
# only when no HARD keyword matched — a rendering-shaped title
|
|
126
|
+
# on a finding that also turns on precedent keys or family
|
|
127
|
+
# attribution is still authority-affecting.
|
|
128
|
+
_EXCLUSION_RES = (
|
|
129
|
+
r"_finding_public", r"prompt (display|truncation|slicing)",
|
|
130
|
+
r"truncat\w* (for|in|into) prompts?", r"\brender\w*",
|
|
131
|
+
r"markdown", r"gate line", r"summary (line|count)")
|
|
132
|
+
_HARD_KEYWORD_RES = (
|
|
133
|
+
r"precedent", r"\bfamil\w*", r"independen", r"\bseats?\b",
|
|
134
|
+
r"\broles?\b", r"quorum", r"coverage", r"record[-_ ]verify",
|
|
135
|
+
r"redact", r"\btier\b", r"tech lead", r"adjudicat")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _finding_text(finding):
|
|
139
|
+
return " ".join(str(finding.get(k) or "") for k in
|
|
140
|
+
("title", "category", "file")).lower()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def tag_authority(finding, overrides=None):
|
|
144
|
+
"""Deterministic authority-affecting tag (§5.5).
|
|
145
|
+
|
|
146
|
+
`overrides` is {"authority": [finding_id, ...],
|
|
147
|
+
"not_authority": [finding_id, ...]} — the short manual
|
|
148
|
+
override list; an override always wins and says so in the
|
|
149
|
+
returned reasons. Returns {"authority_affecting": bool,
|
|
150
|
+
"dimensions": [...], "reasons": [...]}."""
|
|
151
|
+
fid = str(finding.get("id") or "")
|
|
152
|
+
ov = overrides or {}
|
|
153
|
+
if fid and fid in (ov.get("not_authority") or []):
|
|
154
|
+
return {"authority_affecting": False, "dimensions": [],
|
|
155
|
+
"reasons": ["manual override: not authority-"
|
|
156
|
+
"affecting"]}
|
|
157
|
+
if fid and fid in (ov.get("authority") or []):
|
|
158
|
+
return {"authority_affecting": True, "dimensions": [],
|
|
159
|
+
"reasons": ["manual override: authority-"
|
|
160
|
+
"affecting"]}
|
|
161
|
+
text = _finding_text(finding)
|
|
162
|
+
path = str(finding.get("file") or "")
|
|
163
|
+
dimensions, reasons = [], []
|
|
164
|
+
|
|
165
|
+
def hit(dim, why):
|
|
166
|
+
if dim not in dimensions:
|
|
167
|
+
dimensions.append(dim)
|
|
168
|
+
reasons.append(why)
|
|
169
|
+
|
|
170
|
+
for dim, patterns in _PATH_RULES:
|
|
171
|
+
for pat in patterns:
|
|
172
|
+
if re.search(pat, path):
|
|
173
|
+
hit(dim, f"path {path!r} matches {pat!r} ({dim})")
|
|
174
|
+
for dim, patterns in _KEYWORD_RULES:
|
|
175
|
+
for pat in patterns:
|
|
176
|
+
if re.search(pat, text):
|
|
177
|
+
hit(dim, f"keyword {pat!r} ({dim})")
|
|
178
|
+
hard = any(re.search(pat, text) for pat in _HARD_KEYWORD_RES)
|
|
179
|
+
excluded = [pat for pat in _EXCLUSION_RES
|
|
180
|
+
if re.search(pat, text)]
|
|
181
|
+
if excluded and not hard:
|
|
182
|
+
return {"authority_affecting": False, "dimensions": [],
|
|
183
|
+
"reasons": [
|
|
184
|
+
f"excluded: {pat!r} — report rendering / "
|
|
185
|
+
f"prompt display is not authority-affecting"
|
|
186
|
+
for pat in excluded]}
|
|
187
|
+
return {"authority_affecting": bool(dimensions),
|
|
188
|
+
"dimensions": dimensions, "reasons": reasons}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------- disposition store
|
|
192
|
+
|
|
193
|
+
def store_path_for(repo):
|
|
194
|
+
return os.path.join(repo, STORE_FILENAME)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def entry_hash(entry):
|
|
198
|
+
"""Content hash of a store entry: sha256 of its canonical
|
|
199
|
+
JSON (sorted keys, compact separators). Entries never carry
|
|
200
|
+
their own hash; an override references the superseded
|
|
201
|
+
entry by this computed hash."""
|
|
202
|
+
canon = json.dumps(entry, ensure_ascii=False, sort_keys=True,
|
|
203
|
+
separators=(",", ":"))
|
|
204
|
+
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_store(path):
|
|
208
|
+
"""Read the disposition store. Returns {"entries": [...],
|
|
209
|
+
"problems": [...]}. A missing file is an empty store, not
|
|
210
|
+
an error (the store is created lazily on first append)."""
|
|
211
|
+
entries, problems = [], []
|
|
212
|
+
if not path or not os.path.isfile(path):
|
|
213
|
+
return {"entries": entries, "problems": problems}
|
|
214
|
+
with open(path, encoding="utf-8") as fh:
|
|
215
|
+
for lineno, line in enumerate(fh, 1):
|
|
216
|
+
line = line.strip()
|
|
217
|
+
if not line:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
obj = json.loads(line)
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
problems.append(f"{path}:{lineno}: invalid "
|
|
223
|
+
f"JSON ({exc})")
|
|
224
|
+
continue
|
|
225
|
+
if isinstance(obj, dict) and \
|
|
226
|
+
obj.get("schema") == DISPOSITIONS_SCHEMA and \
|
|
227
|
+
"finding_id" not in obj:
|
|
228
|
+
continue # the header line
|
|
229
|
+
if not isinstance(obj, dict) \
|
|
230
|
+
or "finding_id" not in obj:
|
|
231
|
+
problems.append(f"{path}:{lineno}: not a "
|
|
232
|
+
f"disposition entry")
|
|
233
|
+
continue
|
|
234
|
+
entries.append(obj)
|
|
235
|
+
return {"entries": entries, "problems": problems}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def validate_entry(entry, existing=()):
|
|
239
|
+
"""Validate a disposition entry against the schema and the
|
|
240
|
+
entries already in the store. Returns a list of problems
|
|
241
|
+
(empty = valid). The append-only rule: a fingerprint may be
|
|
242
|
+
dispositioned once; a second entry for the same fingerprint
|
|
243
|
+
is valid only as an OVERRIDE that supersedes a named prior
|
|
244
|
+
entry for that fingerprint."""
|
|
245
|
+
problems = []
|
|
246
|
+
if not isinstance(entry, dict):
|
|
247
|
+
return ["entry is not a JSON object"]
|
|
248
|
+
fid = entry.get("finding_id")
|
|
249
|
+
if not isinstance(fid, str) or not _FINGERPRINT_RE.match(fid):
|
|
250
|
+
problems.append("finding_id must be a 12-char lowercase "
|
|
251
|
+
"hex fingerprint")
|
|
252
|
+
typ = entry.get("type")
|
|
253
|
+
if typ not in DISPOSITION_TYPES:
|
|
254
|
+
problems.append(f"type must be one of "
|
|
255
|
+
f"{list(DISPOSITION_TYPES)}")
|
|
256
|
+
if entry.get("decider") not in DECIDERS:
|
|
257
|
+
problems.append(f"decider must be one of "
|
|
258
|
+
f"{list(DECIDERS)}")
|
|
259
|
+
date = entry.get("date")
|
|
260
|
+
if not isinstance(date, str) or not date:
|
|
261
|
+
problems.append("date is required (ISO 8601)")
|
|
262
|
+
else:
|
|
263
|
+
try:
|
|
264
|
+
datetime.datetime.fromisoformat(
|
|
265
|
+
date.replace("Z", "+00:00"))
|
|
266
|
+
except ValueError:
|
|
267
|
+
problems.append(f"date {date!r} is not ISO 8601")
|
|
268
|
+
rc = entry.get("reason_check")
|
|
269
|
+
if rc is not None and rc not in REASON_CHECKS:
|
|
270
|
+
problems.append(f"reason_check must be one of "
|
|
271
|
+
f"{list(REASON_CHECKS)}")
|
|
272
|
+
if entry.get("reason_corrected") is not None and not \
|
|
273
|
+
isinstance(entry.get("reason_corrected"), str):
|
|
274
|
+
problems.append("reason_corrected must be a string")
|
|
275
|
+
basis = entry.get("basis_evidence")
|
|
276
|
+
if basis is not None:
|
|
277
|
+
if not isinstance(basis, list) or not all(
|
|
278
|
+
isinstance(h, str) and _SHA256_RE.match(h)
|
|
279
|
+
for h in basis):
|
|
280
|
+
problems.append("basis_evidence must be a list of "
|
|
281
|
+
"sha256 hashes")
|
|
282
|
+
commit = entry.get("commit")
|
|
283
|
+
if typ == "fixed" and not commit:
|
|
284
|
+
problems.append("a `fixed` disposition requires the "
|
|
285
|
+
"fix commit")
|
|
286
|
+
if commit is not None and not (
|
|
287
|
+
isinstance(commit, str) and _COMMIT_RE.match(commit)):
|
|
288
|
+
problems.append("commit must be a hex commit id "
|
|
289
|
+
"(7-64 chars)")
|
|
290
|
+
marker = entry.get("marker", "ratified")
|
|
291
|
+
if marker not in MARKERS:
|
|
292
|
+
problems.append(f"marker must be one of {list(MARKERS)}")
|
|
293
|
+
prior = [e for e in existing
|
|
294
|
+
if e.get("finding_id") == fid]
|
|
295
|
+
if prior and marker != "override":
|
|
296
|
+
problems.append(
|
|
297
|
+
f"finding {fid} already has a disposition; a second "
|
|
298
|
+
f"entry must be an override (marker='override') "
|
|
299
|
+
f"naming the entry it supersedes")
|
|
300
|
+
if marker == "override":
|
|
301
|
+
sup = entry.get("supersedes")
|
|
302
|
+
if not sup:
|
|
303
|
+
problems.append("an override entry must name the "
|
|
304
|
+
"superseded entry (supersedes = its "
|
|
305
|
+
"entry hash)")
|
|
306
|
+
elif not prior or sup not in {entry_hash(e)
|
|
307
|
+
for e in prior}:
|
|
308
|
+
problems.append("supersedes does not match any prior "
|
|
309
|
+
"entry for this finding")
|
|
310
|
+
return problems
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def append_entry(path, entry):
|
|
314
|
+
"""Validate and append one entry to the store, creating the
|
|
315
|
+
store (with its header line) when absent. Returns the stored
|
|
316
|
+
entry (defaults filled). Raises ValueError with every
|
|
317
|
+
problem named when the entry — or the existing store — is
|
|
318
|
+
not valid: a corrupt store is never appended to silently."""
|
|
319
|
+
loaded = load_store(path)
|
|
320
|
+
if loaded["problems"]:
|
|
321
|
+
raise ValueError("the disposition store has problems; "
|
|
322
|
+
"refusing to append: "
|
|
323
|
+
+ "; ".join(loaded["problems"]))
|
|
324
|
+
stored = dict(entry)
|
|
325
|
+
stored.setdefault("date", _now_iso())
|
|
326
|
+
stored.setdefault("marker", "ratified")
|
|
327
|
+
problems = validate_entry(stored, loaded["entries"])
|
|
328
|
+
if problems:
|
|
329
|
+
raise ValueError("; ".join(problems))
|
|
330
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)),
|
|
331
|
+
exist_ok=True)
|
|
332
|
+
fresh = not os.path.isfile(path)
|
|
333
|
+
needs_nl = False
|
|
334
|
+
if not fresh:
|
|
335
|
+
with open(path, "rb") as fh:
|
|
336
|
+
fh.seek(0, os.SEEK_END)
|
|
337
|
+
if fh.tell() > 0:
|
|
338
|
+
fh.seek(-1, os.SEEK_END)
|
|
339
|
+
needs_nl = fh.read(1) != b"\n"
|
|
340
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
341
|
+
if fresh:
|
|
342
|
+
fh.write(json.dumps(
|
|
343
|
+
{"schema": DISPOSITIONS_SCHEMA,
|
|
344
|
+
"kind": "dispositions",
|
|
345
|
+
"created_at": _now_iso()},
|
|
346
|
+
ensure_ascii=False) + "\n")
|
|
347
|
+
elif needs_nl:
|
|
348
|
+
fh.write("\n")
|
|
349
|
+
fh.write(json.dumps(stored, ensure_ascii=False,
|
|
350
|
+
sort_keys=True) + "\n")
|
|
351
|
+
return stored
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def dispositioned_ids(entries):
|
|
355
|
+
"""Finding fingerprints the store dispositions (any entry;
|
|
356
|
+
an override replaces its target but the finding stays
|
|
357
|
+
dispositioned)."""
|
|
358
|
+
return {str(e.get("finding_id")) for e in entries or []
|
|
359
|
+
if e.get("finding_id")}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def consumed_entries(entries, finding_ids):
|
|
363
|
+
"""The store entries record-verify consumed for a set of
|
|
364
|
+
finding ids — what was relied on, reported, never hidden."""
|
|
365
|
+
wanted = set(finding_ids or ())
|
|
366
|
+
return [e for e in entries or []
|
|
367
|
+
if e.get("finding_id") in wanted]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------- record scanning
|
|
371
|
+
|
|
372
|
+
def iter_record_files(root):
|
|
373
|
+
"""Record JSONs under the top-level review-*/ and review_*/
|
|
374
|
+
directories of `root` — the committed run output. Companion
|
|
375
|
+
.md files and evidence sidecars are not records."""
|
|
376
|
+
out = []
|
|
377
|
+
if not root or not os.path.isdir(root):
|
|
378
|
+
return out
|
|
379
|
+
for name in sorted(os.listdir(root)):
|
|
380
|
+
if not (name.startswith("review-")
|
|
381
|
+
or name.startswith("review_")):
|
|
382
|
+
continue
|
|
383
|
+
dirp = os.path.join(root, name)
|
|
384
|
+
if not os.path.isdir(dirp):
|
|
385
|
+
continue
|
|
386
|
+
for fname in sorted(os.listdir(dirp)):
|
|
387
|
+
if fname.endswith(".json"):
|
|
388
|
+
out.append(os.path.join(dirp, fname))
|
|
389
|
+
return out
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def load_record(path):
|
|
393
|
+
"""Parse a record file; returns the record dict, or None
|
|
394
|
+
when the file is not a codereview record."""
|
|
395
|
+
try:
|
|
396
|
+
with open(path, encoding="utf-8") as fh:
|
|
397
|
+
rec = json.load(fh)
|
|
398
|
+
except (OSError, ValueError):
|
|
399
|
+
return None
|
|
400
|
+
if not isinstance(rec, dict):
|
|
401
|
+
return None
|
|
402
|
+
if not str(rec.get("schema") or "").startswith(
|
|
403
|
+
"codereview.report"):
|
|
404
|
+
return None
|
|
405
|
+
return rec
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _record_disputes(record):
|
|
409
|
+
"""Findings of one record sitting in a human-decision
|
|
410
|
+
state, deduped by id (a merged finding is serialized into
|
|
411
|
+
several lists). Status comes from the finding's own status
|
|
412
|
+
field, falling back to list membership — the two agree in
|
|
413
|
+
every record the current machinery writes."""
|
|
414
|
+
found = {}
|
|
415
|
+
for list_key in core._FINDING_LIST_KEYS:
|
|
416
|
+
for f in record.get(list_key) or []:
|
|
417
|
+
if not isinstance(f, dict) or not f.get("id"):
|
|
418
|
+
continue
|
|
419
|
+
status = f.get("status")
|
|
420
|
+
if status not in OPEN_STATUSES:
|
|
421
|
+
if list_key == "disputed_findings":
|
|
422
|
+
status = "disputed"
|
|
423
|
+
elif list_key == "refutation_weak_findings":
|
|
424
|
+
status = "refutation_weak"
|
|
425
|
+
elif list_key == "escalated_findings":
|
|
426
|
+
status = "escalated"
|
|
427
|
+
else:
|
|
428
|
+
continue
|
|
429
|
+
cur = found.get(f["id"])
|
|
430
|
+
if cur is None or (isinstance(f.get("dispute"), dict)
|
|
431
|
+
and not isinstance(
|
|
432
|
+
cur.get("dispute"), dict)):
|
|
433
|
+
g = dict(f)
|
|
434
|
+
g["status"] = status
|
|
435
|
+
found[f["id"]] = g
|
|
436
|
+
return list(found.values())
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _lens_for(record, finding):
|
|
440
|
+
"""The lens that raised a finding, from the reviewer report
|
|
441
|
+
that carries it (best effort; old records may not say)."""
|
|
442
|
+
for rep in record.get("reviewer_reports") or []:
|
|
443
|
+
if not isinstance(rep, dict):
|
|
444
|
+
continue
|
|
445
|
+
for f in rep.get("findings") or []:
|
|
446
|
+
if isinstance(f, dict) \
|
|
447
|
+
and f.get("id") == finding.get("id"):
|
|
448
|
+
return rep.get("lens")
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def dispute_summary(record, finding, record_rel):
|
|
453
|
+
"""The inventory row for one open dispute."""
|
|
454
|
+
dispute = finding.get("dispute")
|
|
455
|
+
dispute = dispute if isinstance(dispute, dict) else {}
|
|
456
|
+
adjudication = finding.get("adjudication")
|
|
457
|
+
adjudication = adjudication \
|
|
458
|
+
if isinstance(adjudication, dict) else {}
|
|
459
|
+
sources = [s for s in (finding.get("sources") or [])
|
|
460
|
+
if s != "deterministic"]
|
|
461
|
+
return {
|
|
462
|
+
"id": finding.get("id"),
|
|
463
|
+
"record": record_rel,
|
|
464
|
+
"status": finding.get("status"),
|
|
465
|
+
"escalation_reason":
|
|
466
|
+
adjudication.get("escalation_reason"),
|
|
467
|
+
"severity": finding.get("severity"),
|
|
468
|
+
"category": finding.get("category"),
|
|
469
|
+
"file": finding.get("file"),
|
|
470
|
+
"line": finding.get("line"),
|
|
471
|
+
"line_basis": finding.get("line_basis"),
|
|
472
|
+
"title": finding.get("title"),
|
|
473
|
+
"lens": _lens_for(record, finding),
|
|
474
|
+
"raiser": sources[0] if sources else None,
|
|
475
|
+
"raiser_family": dispute.get("raiser_family"),
|
|
476
|
+
"refuter": dispute.get("by"),
|
|
477
|
+
"refuter_family": dispute.get("family"),
|
|
478
|
+
"adequacy": dispute.get("adequacy"),
|
|
479
|
+
"evidence_complete": finding.get("evidence_complete"),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def build_inventory(root, entries=(), overrides=None):
|
|
484
|
+
"""The computed inventory (§5.1): every open dispute in
|
|
485
|
+
every record under `root`, split by whether the disposition
|
|
486
|
+
store dispositions it. Returns {"open": [...],
|
|
487
|
+
"dispositioned": [...], "records_scanned": n,
|
|
488
|
+
"store_entries": n}. Each open row carries the
|
|
489
|
+
authority-affecting tag."""
|
|
490
|
+
disp = dispositioned_ids(entries)
|
|
491
|
+
open_rows, done_rows = [], []
|
|
492
|
+
scanned = 0
|
|
493
|
+
seen = set()
|
|
494
|
+
for path in iter_record_files(root):
|
|
495
|
+
record = load_record(path)
|
|
496
|
+
if record is None:
|
|
497
|
+
continue
|
|
498
|
+
scanned += 1
|
|
499
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
500
|
+
for f in _record_disputes(record):
|
|
501
|
+
row = dispute_summary(record, f, rel)
|
|
502
|
+
if row["id"] in seen:
|
|
503
|
+
# The same dispute serialized into a second
|
|
504
|
+
# record (a copied or re-exported record file)
|
|
505
|
+
# is ONE dispute: the inventory counts distinct
|
|
506
|
+
# disputes, not record files (0bb1b939ab67 —
|
|
507
|
+
# a duplicated record doubled the computed
|
|
508
|
+
# inventory and would have doubled freshness
|
|
509
|
+
# violations).
|
|
510
|
+
continue
|
|
511
|
+
seen.add(row["id"])
|
|
512
|
+
if row["id"] in disp:
|
|
513
|
+
done_rows.append(row)
|
|
514
|
+
else:
|
|
515
|
+
row["authority"] = tag_authority(f, overrides)
|
|
516
|
+
open_rows.append(row)
|
|
517
|
+
return {"open": open_rows, "dispositioned": done_rows,
|
|
518
|
+
"records_scanned": scanned,
|
|
519
|
+
"store_entries": len(list(entries or []))}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ------------------------------------------------------- freshness (§5.3)
|
|
523
|
+
|
|
524
|
+
def _git_lines(repo, *args):
|
|
525
|
+
out = subprocess.run(["git", "-C", repo, *args],
|
|
526
|
+
capture_output=True, timeout=120,
|
|
527
|
+
check=False)
|
|
528
|
+
if out.returncode != 0:
|
|
529
|
+
return None
|
|
530
|
+
return out.stdout.decode("utf-8", "replace").split()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _commit_set(repo, spec):
|
|
534
|
+
lines = _git_lines(repo, "rev-list", spec)
|
|
535
|
+
return set(lines) if lines is not None else None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def records_touching_span(repo, root, span):
|
|
539
|
+
"""Split the records under `root` into (touching,
|
|
540
|
+
unscoped): a record touches span A..B when its recorded
|
|
541
|
+
range's commits intersect the span's, or its recorded head
|
|
542
|
+
is in the span. A record with no range/head, or whose range
|
|
543
|
+
cannot be recomputed, is unscoped — the check names it
|
|
544
|
+
rather than silently treating it as either."""
|
|
545
|
+
span_commits = _commit_set(repo, span)
|
|
546
|
+
if span_commits is None:
|
|
547
|
+
raise ValueError(f"span {span!r} does not resolve in "
|
|
548
|
+
f"{repo!r}")
|
|
549
|
+
# Range/head resolutions are cached per scan: records
|
|
550
|
+
# overwhelmingly share a handful of range specs, and an
|
|
551
|
+
# uncached rev-list per record made the scan O(records)
|
|
552
|
+
# subprocess calls (e9ef62daa1a0). Each git call stays
|
|
553
|
+
# individually timeout-bounded (see _git_lines).
|
|
554
|
+
cache = {span: span_commits}
|
|
555
|
+
|
|
556
|
+
def range_commits(spec):
|
|
557
|
+
if spec not in cache:
|
|
558
|
+
cache[spec] = _commit_set(repo, spec)
|
|
559
|
+
return cache[spec]
|
|
560
|
+
|
|
561
|
+
def head_commit(sha):
|
|
562
|
+
key = ("head", sha)
|
|
563
|
+
if key not in cache:
|
|
564
|
+
one = _git_lines(repo, "rev-parse", "--verify",
|
|
565
|
+
f"{sha}^{{commit}}")
|
|
566
|
+
cache[key] = set(one) if one else None
|
|
567
|
+
return cache[key]
|
|
568
|
+
|
|
569
|
+
touching, unscoped = [], []
|
|
570
|
+
for path in iter_record_files(root):
|
|
571
|
+
record = load_record(path)
|
|
572
|
+
if record is None:
|
|
573
|
+
continue
|
|
574
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
575
|
+
commits = None
|
|
576
|
+
rng = record.get("range")
|
|
577
|
+
if rng:
|
|
578
|
+
commits = range_commits(str(rng))
|
|
579
|
+
if not commits and record.get("head"):
|
|
580
|
+
# Fall back to the recorded head when the range
|
|
581
|
+
# resolves to nothing usable — including the
|
|
582
|
+
# degenerate X..X range, whose rev-list is an
|
|
583
|
+
# EMPTY set (not None): without this, such a
|
|
584
|
+
# record was silently dropped from the check
|
|
585
|
+
# altogether (55c815ff5fbe).
|
|
586
|
+
commits = head_commit(str(record["head"]))
|
|
587
|
+
if not commits:
|
|
588
|
+
unscoped.append(rel)
|
|
589
|
+
elif commits & span_commits:
|
|
590
|
+
touching.append(rel)
|
|
591
|
+
return touching, unscoped
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def inventory_ids_from_file(path):
|
|
595
|
+
"""Finding ids named by a caller-supplied inventory file:
|
|
596
|
+
a JSON list of ids/rows, an object with open/items/
|
|
597
|
+
open_disputes lists, or a bare ids list — including this
|
|
598
|
+
command's own --json output."""
|
|
599
|
+
with open(path, encoding="utf-8") as fh:
|
|
600
|
+
data = json.load(fh)
|
|
601
|
+
ids = set()
|
|
602
|
+
items = []
|
|
603
|
+
if isinstance(data, list):
|
|
604
|
+
items = data
|
|
605
|
+
elif isinstance(data, dict):
|
|
606
|
+
for key in ("open", "items", "open_disputes"):
|
|
607
|
+
if isinstance(data.get(key), list):
|
|
608
|
+
items.extend(data[key])
|
|
609
|
+
if isinstance(data.get("ids"), list):
|
|
610
|
+
items.extend(data["ids"])
|
|
611
|
+
for item in items:
|
|
612
|
+
if isinstance(item, str):
|
|
613
|
+
ids.add(item)
|
|
614
|
+
elif isinstance(item, dict):
|
|
615
|
+
fid = item.get("id") or item.get("finding_id")
|
|
616
|
+
if fid:
|
|
617
|
+
ids.add(str(fid))
|
|
618
|
+
return ids
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def check_freshness(repo, root, span, entries=(),
|
|
622
|
+
inventory_ids=()):
|
|
623
|
+
"""The release-flow gate (§5.3): open disputes in records
|
|
624
|
+
touching `span` that are neither dispositioned by the store
|
|
625
|
+
nor named in the caller's inventory. Returns {"violations":
|
|
626
|
+
[row, ...], "touching": [...], "unscoped": [...]}."""
|
|
627
|
+
touching, unscoped = records_touching_span(repo, root, span)
|
|
628
|
+
inv = build_inventory(root, entries)
|
|
629
|
+
keep = set(inventory_ids or ())
|
|
630
|
+
violations = [row for row in inv["open"]
|
|
631
|
+
if row["record"] in set(touching)
|
|
632
|
+
and row["id"] not in keep]
|
|
633
|
+
return {"violations": violations, "touching": touching,
|
|
634
|
+
"unscoped": unscoped}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ------------------------------------------------------- locate (§5.6)
|
|
638
|
+
|
|
639
|
+
def _full_text(container, key, evidence_dir):
|
|
640
|
+
"""A finding text at full length: the sidecar content when
|
|
641
|
+
the record's display copy is truncated, else the record's
|
|
642
|
+
own text. Returns (text, source)."""
|
|
643
|
+
text = container.get(key)
|
|
644
|
+
ref = container.get(key + "_ref")
|
|
645
|
+
if isinstance(ref, dict) and ref.get("truncated") \
|
|
646
|
+
and evidence_dir:
|
|
647
|
+
path = os.path.join(evidence_dir, str(ref.get("sha256")
|
|
648
|
+
or ""))
|
|
649
|
+
if os.path.isfile(path):
|
|
650
|
+
with open(path, encoding="utf-8") as fh:
|
|
651
|
+
return fh.read(), "sidecar"
|
|
652
|
+
return (text if isinstance(text, str) else ""), "record"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _find_finding(record, finding_id):
|
|
656
|
+
for list_key in core._FINDING_LIST_KEYS:
|
|
657
|
+
for f in record.get(list_key) or []:
|
|
658
|
+
if isinstance(f, dict) and f.get("id") == finding_id:
|
|
659
|
+
return f
|
|
660
|
+
for rep in record.get("reviewer_reports") or []:
|
|
661
|
+
if isinstance(rep, dict):
|
|
662
|
+
for f in rep.get("findings") or []:
|
|
663
|
+
if isinstance(f, dict) \
|
|
664
|
+
and f.get("id") == finding_id:
|
|
665
|
+
return f
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _quote_positions(quote, blob_text):
|
|
670
|
+
"""1-based blob line numbers whose text matches the quote
|
|
671
|
+
under the grounding tolerances (strip, a kept '+' marker,
|
|
672
|
+
whitespace collapsing), plus a whole-blob collapsed-
|
|
673
|
+
substring fallback for multi-line quotes."""
|
|
674
|
+
q = core._norm_quote(quote)
|
|
675
|
+
if not q or not blob_text:
|
|
676
|
+
return []
|
|
677
|
+
positions = []
|
|
678
|
+
lines = blob_text.splitlines()
|
|
679
|
+
for i, line in enumerate(lines, 1):
|
|
680
|
+
norm = core._norm_quote(line)
|
|
681
|
+
if norm == q or (len(q) >= 8 and q in norm):
|
|
682
|
+
positions.append(i)
|
|
683
|
+
if positions:
|
|
684
|
+
return positions
|
|
685
|
+
collapsed = " ".join(blob_text.split())
|
|
686
|
+
idx = collapsed.find(q)
|
|
687
|
+
if idx >= 0:
|
|
688
|
+
upto = collapsed[:idx]
|
|
689
|
+
# Count newlines in the ORIGINAL text up to the same
|
|
690
|
+
# word: approximate by word index over split lines.
|
|
691
|
+
words_before = len(upto.split())
|
|
692
|
+
seen = 0
|
|
693
|
+
for i, line in enumerate(lines, 1):
|
|
694
|
+
seen += len(line.split())
|
|
695
|
+
if seen > words_before:
|
|
696
|
+
return [i]
|
|
697
|
+
return []
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def locate_finding(record, finding_id, repo, record_dir=None):
|
|
701
|
+
"""Resolve a finding's validated quote to its blob
|
|
702
|
+
position(s) at the reviewed commit (§5.6). The finding's
|
|
703
|
+
own evidence_quote is the primary anchor; the refuter's
|
|
704
|
+
dispute quote is located too when present. Legacy findings
|
|
705
|
+
without line_basis, and records without a recorded head
|
|
706
|
+
(the repo HEAD is used and named), locate exactly the same
|
|
707
|
+
way — the quote is the anchor, not the number."""
|
|
708
|
+
finding = _find_finding(record, finding_id)
|
|
709
|
+
if finding is None:
|
|
710
|
+
return None
|
|
711
|
+
evidence_dir = (os.path.join(repo, record_dir, "evidence")
|
|
712
|
+
if repo and record_dir else None)
|
|
713
|
+
quote, qsrc = _full_text(finding, "evidence_quote",
|
|
714
|
+
evidence_dir)
|
|
715
|
+
quote_source = "finding"
|
|
716
|
+
if not (quote or "").strip():
|
|
717
|
+
dispute = finding.get("dispute")
|
|
718
|
+
if isinstance(dispute, dict):
|
|
719
|
+
quote, qsrc = _full_text(dispute, "evidence_quote",
|
|
720
|
+
evidence_dir)
|
|
721
|
+
quote_source = "dispute"
|
|
722
|
+
ref = str(record.get("head") or "HEAD")
|
|
723
|
+
blob_text, blob_error = None, ""
|
|
724
|
+
path = str(finding.get("file") or "")
|
|
725
|
+
if repo and path:
|
|
726
|
+
try:
|
|
727
|
+
blob_text = core._git_show_bytes(
|
|
728
|
+
repo, f"{ref}:{path}").decode("utf-8", "replace")
|
|
729
|
+
except Exception as exc: # noqa: BLE001
|
|
730
|
+
blob_error = str(exc)
|
|
731
|
+
positions = _quote_positions(quote, blob_text) \
|
|
732
|
+
if blob_text is not None else []
|
|
733
|
+
cited = finding.get("line")
|
|
734
|
+
return {
|
|
735
|
+
"finding_id": finding_id,
|
|
736
|
+
"file": path,
|
|
737
|
+
"title": finding.get("title"),
|
|
738
|
+
"status": finding.get("status"),
|
|
739
|
+
"cited_line": cited,
|
|
740
|
+
"line_basis": finding.get("line_basis"),
|
|
741
|
+
"quote_source": quote_source,
|
|
742
|
+
"quote_from": qsrc,
|
|
743
|
+
"quote": quote,
|
|
744
|
+
"blob_ref": ref,
|
|
745
|
+
"blob_error": blob_error or None,
|
|
746
|
+
"positions": positions,
|
|
747
|
+
"cited_matches": bool(
|
|
748
|
+
cited is not None and cited in positions),
|
|
749
|
+
}
|