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/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
+ }