Coverage for src/keel/revertcheck.py: 100%

531 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""The opt-in ``revert-check`` gate (#1289): does any test notice when a change is undone? 

2 

3Coverage proves that a line *ran*, not that an assertion *depends* on it. With 

4``fail_under = 100`` enforced, "maintained 100 % coverage" is true of every merged pull 

5request before anyone writes it, and an audit of 14 closed fixes found three whose tests 

6passed with the fix removed. This gate asks the question coverage cannot: for each 

7production change on the branch, revert **that change alone** in a scratch worktree, run 

8the project's tests, and require a test to **fail as an assertion**. 

9 

10It is mutation testing scoped to one mutation per change — the change itself. 

11 

12This module is the pure half, and deterministic: 

13 

14* :func:`resolve` — ``knobs.revert_check`` (+ ``build_gate_cmd``, ``gate_timeout_s``) -> 

15 :class:`Settings`; 

16* :func:`test_paths` — where the tests live, from ``policy_pack.test_groups.*.test_paths``; 

17* :func:`parse_diff` / :func:`plan_changes` — the branch's unified diff -> the production 

18 changes to revert, each with the patch that undoes it; 

19* :func:`read_output` / :func:`classify` — a test run's output -> *caught* (a test failed as 

20 an assertion), *errored*, *unnoticed*, *timed out* or *unreadable*; 

21* :func:`execute` — the bounded loop (``max_changes``, ``budget_s``), driven through an 

22 injected ``run`` callable and ``clock``, so the loop is tested offline; 

23* :func:`judge` — the gate's verdict and findings. 

24 

25The git and subprocess work — the scratch worktree, ``git apply -R``, the test command — 

26lives in :mod:`keel.cli` through :mod:`keel.git` and :mod:`keel.runner`; nothing here 

27touches the file system. 

28 

29**What it does not do.** The unit is a git hunk (or a file), not a *behaviour*: #871's 

30guarded and unguarded arms shared one hunk, so a per-hunk check passes while half that fix 

31is unpinned. And a passing check is necessary, not sufficient: #873's test fails without 

32its fix and still could not see the regression the fix shipped (#1268), because its fixture 

33made the fix and the bug agree. Both remain a reviewer's questions. 

34""" 

35 

36from __future__ import annotations 

37 

38import ast 

39import fnmatch 

40import posixpath 

41import re 

42from collections.abc import Callable, Mapping, Sequence 

43from dataclasses import dataclass 

44from typing import Any 

45 

46from .findings import Finding 

47 

48#: The gate id, as ``gates:`` lists it. 

49GATE_ID = "revert-check" 

50 

51#: ``knobs.revert_check.unit``: revert each hunk alone (the default), or each file. 

52UNIT_HUNK = "hunk" 

53UNIT_FILE = "file" 

54UNITS = (UNIT_HUNK, UNIT_FILE) 

55 

56#: At most this many changes are reverted per run unless ``max_changes`` says otherwise. 

57DEFAULT_MAX_CHANGES = 10 

58#: Wall-clock seconds the whole check — the baseline run and every revert — may spend. 

59DEFAULT_BUDGET_S = 1800 

60 

61#: What counts as production when ``knobs.revert_check.paths`` is not set: a changed file 

62#: with a source-code suffix that is not under the project's test paths. Docs, config, 

63#: lockfiles and data are left out — reverting a README cannot make a test fail, and 

64#: reporting it as unnoticed would be noise, not evidence. 

65SOURCE_SUFFIXES: tuple[str, ...] = ( 

66 ".py", 

67 ".pyi", 

68 ".js", 

69 ".jsx", 

70 ".mjs", 

71 ".cjs", 

72 ".ts", 

73 ".tsx", 

74 ".go", 

75 ".rs", 

76 ".java", 

77 ".kt", 

78 ".kts", 

79 ".scala", 

80 ".rb", 

81 ".php", 

82 ".cs", 

83 ".fs", 

84 ".c", 

85 ".h", 

86 ".cc", 

87 ".cpp", 

88 ".cxx", 

89 ".hpp", 

90 ".m", 

91 ".mm", 

92 ".swift", 

93 ".dart", 

94 ".ex", 

95 ".exs", 

96 ".erl", 

97 ".clj", 

98 ".lua", 

99 ".pl", 

100 ".pm", 

101 ".r", 

102 ".sh", 

103 ".bash", 

104) 

105 

106# Per-change results. 

107CAUGHT = "caught" # a test failed as an assertion 

108ERRORED = "errored" # tests failed, none as an assertion (an exception, an import error) 

109UNNOTICED = "unnoticed" # the test command passed with the change reverted 

110TIMED_OUT = "timed-out" 

111UNREADABLE = "unreadable" # the command failed and its output says no test failed 

112NOT_APPLIED = "not-applied" # the reverse patch did not apply on HEAD 

113 

114_BLOCK = "major" 

115 

116 

117@dataclass(frozen=True) 

118class Settings: 

119 """The resolved ``knobs.revert_check`` for one run.""" 

120 

121 #: The test command each revert runs; ``None`` when neither knob sets one. 

122 cmd: str | None 

123 #: The knob :attr:`cmd` came from (``knobs.build_gate_cmd`` when neither sets one). 

124 cmd_source: str 

125 #: Globs that select production files; empty means :data:`SOURCE_SUFFIXES`. 

126 paths: tuple[str, ...] 

127 unit: str 

128 max_changes: int 

129 budget_s: int 

130 #: The wall-clock limit of one test run (``knobs.gate_timeout_s``). 

131 run_timeout_s: int 

132 

133 

134def resolve( 

135 knob: Mapping[str, Any] | None, *, build_cmd: str | None, gate_timeout_s: int 

136) -> Settings: 

137 """``knobs.revert_check`` -> :class:`Settings`, defaults filled in. 

138 

139 The test command is ``knobs.revert_check.cmd`` when set, else ``knobs.build_gate_cmd``: 

140 a project whose suite is slow can point the check at the tests that matter without 

141 changing what its ``build`` gate runs. The schema owns the value shapes; anything it 

142 would refuse reads as the default here, because this resolver runs on every gate run. 

143 """ 

144 raw = knob if isinstance(knob, Mapping) else {} 

145 own = raw.get("cmd") 

146 if isinstance(own, str) and own.strip(): 

147 cmd: str | None = own 

148 source = "knobs.revert_check.cmd" 

149 else: 

150 cmd = build_cmd if isinstance(build_cmd, str) and build_cmd.strip() else None 

151 source = "knobs.build_gate_cmd" 

152 paths = raw.get("paths") 

153 unit = raw.get("unit") 

154 return Settings( 

155 cmd=cmd, 

156 cmd_source=source, 

157 paths=tuple(_strings(paths)), 

158 unit=unit if unit in UNITS else UNIT_HUNK, 

159 max_changes=_positive(raw.get("max_changes"), DEFAULT_MAX_CHANGES), 

160 budget_s=_positive(raw.get("budget_s"), DEFAULT_BUDGET_S), 

161 run_timeout_s=max(1, int(gate_timeout_s)), 

162 ) 

163 

164 

165def _positive(value: Any, default: int) -> int: 

166 """A positive integer knob, or ``default`` (``bool`` is not a number here).""" 

167 if isinstance(value, int) and not isinstance(value, bool) and value >= 1: 

168 return value 

169 return default 

170 

171 

172def _strings(raw: Any) -> list[str]: 

173 """Non-blank string entries of a list (anything else contributes nothing).""" 

174 if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)): 

175 return [] 

176 return [entry.strip() for entry in raw if isinstance(entry, str) and entry.strip()] 

177 

178 

179def test_paths(policy_pack: Mapping[str, Any] | None) -> tuple[str, ...]: 

180 """Where the project's tests live: every ``policy_pack.test_groups.*.test_paths`` glob. 

181 

182 Only the **declared** ``test_paths`` count. A group's ``paths`` are selectors and 

183 routinely include the implementation surface (keel's own ``unit`` group selects 

184 ``src/**``); read as test paths they would classify every production file as a test, 

185 and the gate would skip every change it exists to check. 

186 """ 

187 pack = policy_pack if isinstance(policy_pack, Mapping) else {} 

188 groups = pack.get("test_groups") 

189 if not isinstance(groups, Mapping): 

190 return () 

191 globs: list[str] = [] 

192 for _name, group in sorted(groups.items(), key=lambda item: str(item[0])): 

193 if isinstance(group, Mapping): 

194 globs.extend(_strings(group.get("test_paths"))) 

195 return tuple(dict.fromkeys(globs)) 

196 

197 

198def is_production(path: str, *, tests: Sequence[str], paths: Sequence[str]) -> bool: 

199 """Is ``path`` production code this gate reverts? 

200 

201 Never a test path. Then ``paths`` (``fnmatch`` globs, the matcher ``tier3_globs`` and 

202 ``test_paths`` use) when the project set it, else a source-code suffix. 

203 """ 

204 if any(fnmatch.fnmatch(path, glob) for glob in tests): 

205 return False 

206 if paths: 

207 return any(fnmatch.fnmatch(path, glob) for glob in paths) 

208 return path.lower().endswith(SOURCE_SUFFIXES) 

209 

210 

211# --- the diff ------------------------------------------------------------------------ 

212 

213_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") 

214 

215 

216@dataclass(frozen=True) 

217class Hunk: 

218 """One ``@@`` hunk: its header line and body, verbatim.""" 

219 

220 header: str 

221 lines: tuple[str, ...] 

222 added: int 

223 removed: int 

224 #: Context lines. :func:`keel.git.revert_diff` asks for none, so any here means the 

225 #: diff is not the shape the plan needs (:func:`context_problem`). 

226 context: int = 0 

227 

228 @property 

229 def pure_addition(self) -> bool: 

230 """Only adds lines: reverting it removes code rather than restoring old code.""" 

231 return self.removed == 0 

232 

233 

234@dataclass(frozen=True) 

235class FileDiff: 

236 """One file's part of a unified diff.""" 

237 

238 path: str 

239 #: The lines from ``diff --git`` up to the first hunk — what ``git apply`` needs to 

240 #: know which file, and whether it is new or deleted. 

241 header: tuple[str, ...] 

242 hunks: tuple[Hunk, ...] 

243 status: str # added | deleted | modified 

244 

245 @property 

246 def mode(self) -> tuple[str, str] | None: 

247 """``(old, new)`` when the diff changes the file's mode (``old mode`` / ``new mode``).""" 

248 old = next((line[9:] for line in self.header if line.startswith("old mode ")), None) 

249 new = next((line[9:] for line in self.header if line.startswith("new mode ")), None) 

250 return (old, new) if old and new else None 

251 

252 @property 

253 def binary(self) -> bool: 

254 """git printed no text for it (``Binary files … differ``).""" 

255 return any(line.startswith(("Binary files ", "GIT binary patch")) for line in self.header) 

256 

257 @property 

258 def content_header(self) -> tuple[str, ...]: 

259 """The header without its mode lines: a content hunk must not carry the mode along.""" 

260 return tuple( 

261 line for line in self.header if not line.startswith(("old mode ", "new mode ")) 

262 ) 

263 

264 

265#: git's C-style escapes in a quoted path (``quote_c_style``), beside ``\\ooo`` octal bytes. 

266_C_ESCAPES = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13, '"': 34, "\\": 92} 

267 

268 

269def _unquote(name: str) -> str: 

270 """A path as git wrote it -> the path itself. 

271 

272 git wraps a name in double quotes when it holds a control character, a quote or a 

273 backslash, and escapes those inside (``"a/q\\"x.py"``); a byte is ``\\ooo`` octal. An 

274 unquoted name is returned as it is. 

275 """ 

276 if len(name) < 2 or not (name.startswith('"') and name.endswith('"')): 

277 return name 

278 body, out, i = name[1:-1], bytearray(), 0 

279 while i < len(body): 

280 char = body[i] 

281 octal = body[i + 1 : i + 4] 

282 if char == "\\" and body[i + 1 : i + 2] in _C_ESCAPES: 

283 out.append(_C_ESCAPES[body[i + 1]]) 

284 i += 2 

285 elif char == "\\" and len(octal) == 3 and all(c in "01234567" for c in octal): 

286 out.append(int(octal, 8) & 0xFF) 

287 i += 4 

288 else: 

289 out += char.encode("utf-8", "surrogateescape") 

290 i += 1 

291 return out.decode("utf-8", "surrogateescape") 

292 

293 

294def _closing_quote(text: str) -> int: 

295 """The index of the quote that closes the quoted name opening ``text``; ``-1`` if none.""" 

296 i = 1 

297 while i < len(text): 

298 if text[i] == "\\": 

299 i += 2 

300 continue 

301 if text[i] == '"': 

302 return i 

303 i += 1 

304 return -1 

305 

306 

307def _strip(name: str, prefix: str) -> str: 

308 return name[len(prefix) :] if name.startswith(prefix) else name 

309 

310 

311def _path_from(line: str, prefix: str) -> str | None: 

312 """The path on a ``--- a/x`` / ``+++ b/x`` line; ``None`` for ``/dev/null``. 

313 

314 git ends the line with a tab when the name contains a space, and quotes a name that 

315 holds a control character, a quote or a backslash (:func:`_unquote`); the path is read 

316 back so it matches the project's globs. 

317 """ 

318 name = line[4:].rstrip("\t") 

319 if name == "/dev/null": 

320 return None 

321 return _strip(_unquote(name), prefix) 

322 

323 

324def _header_path(line: str) -> str: 

325 """The path on a ``diff --git a/x b/x`` line — the only name a file with no ``---`` / 

326 ``+++`` lines (a mode change, a binary file) carries. 

327 

328 With renames off both names are the same file, so an unquoted pair splits in the middle 

329 (a name may hold ``" b/"``), and a quoted one ends at its closing quote. A header that 

330 reads neither way falls back to the text after the last ``" b/"``. 

331 """ 

332 rest = line[len("diff --git ") :] 

333 if rest.startswith('"'): 

334 end = _closing_quote(rest) 

335 if end > 0: 

336 return _strip(_unquote(rest[: end + 1]), "a/") 

337 half = (len(rest) - 5) // 2 

338 name = rest[2 : 2 + half] 

339 if rest.startswith("a/") and rest[2 + half :] == f" b/{name}": 

340 return name 

341 return rest.rsplit(" b/", 1)[-1] 

342 

343 

344def parse_diff(text: str) -> tuple[FileDiff, ...]: 

345 """Split ``git diff --no-renames --src-prefix=a/ --dst-prefix=b/`` output into files. 

346 

347 Hunk bodies are consumed by the counts in their ``@@`` header, not by their first 

348 character, so a blank context line (``diff.suppressBlankEmpty``) cannot end a hunk 

349 early. A file with no hunk — binary, or a mode change — is kept with ``hunks=()``, so 

350 the plan can say it was not checked rather than drop it. 

351 """ 

352 files: list[FileDiff] = [] 

353 lines = text.split("\n") 

354 i = 0 

355 while i < len(lines): 

356 if not lines[i].startswith("diff --git "): 

357 i += 1 

358 continue 

359 header = [lines[i]] 

360 fallback = _header_path(lines[i]) 

361 old = new = None 

362 i += 1 

363 while i < len(lines) and not lines[i].startswith(("@@", "diff --git ")): 

364 line = lines[i] 

365 if line.startswith("--- "): 

366 old = _path_from(line, "a/") 

367 elif line.startswith("+++ "): 

368 new = _path_from(line, "b/") 

369 header.append(line) 

370 i += 1 

371 hunks: list[Hunk] = [] 

372 while i < len(lines) and lines[i].startswith("@@"): 

373 hunk, i = _read_hunk(lines, i) 

374 hunks.append(hunk) 

375 text_header = "\n".join(header) 

376 if "\nnew file mode " in text_header: 

377 status = "added" 

378 elif "\ndeleted file mode " in text_header: 

379 status = "deleted" 

380 else: 

381 status = "modified" 

382 files.append(FileDiff(new or old or fallback, tuple(header), tuple(hunks), status)) 

383 return tuple(files) 

384 

385 

386def _read_hunk(lines: list[str], i: int) -> tuple[Hunk, int]: 

387 """Read the hunk whose header is ``lines[i]``; return it and the index after it.""" 

388 header = lines[i] 

389 match = _HUNK_RE.match(header) 

390 old_left = int(match.group(2) or "1") if match else 0 

391 new_left = int(match.group(4) or "1") if match else 0 

392 body: list[str] = [] 

393 added = removed = context = 0 

394 i += 1 

395 while i < len(lines) and (old_left > 0 or new_left > 0): 

396 line = lines[i] 

397 tag = line[:1] 

398 if tag == "+": 

399 added += 1 

400 new_left -= 1 

401 elif tag == "-": 

402 removed += 1 

403 old_left -= 1 

404 elif tag == "\\": 

405 pass # "\ No newline at end of file" belongs to the line before it 

406 else: # context, including an empty line git wrote for a blank one 

407 old_left -= 1 

408 new_left -= 1 

409 context += 1 

410 body.append(line) 

411 i += 1 

412 # A trailing "\ No newline at end of file" marker after the last counted line. 

413 while i < len(lines) and lines[i].startswith("\\"): 

414 body.append(lines[i]) 

415 i += 1 

416 return Hunk(header, tuple(body), added, removed, context), i 

417 

418 

419def context_problem(files: Sequence[FileDiff]) -> str | None: 

420 """Why the diff cannot be split into independent changes, or ``None`` when it can. 

421 

422 Every hunk must carry **no context line**. :func:`keel.git.revert_diff` pins 

423 ``--unified=0`` and ``--inter-hunk-context=0``, but ``GIT_DIFF_OPTS`` or a setting keel 

424 does not know could still widen a hunk — and a hunk that carries context is either 

425 two edits merged into one change, where one caught edit passes the other, or a patch 

426 whose context ``--unidiff-zero`` would misplace. Checked here, on what git printed. 

427 """ 

428 widened = [f.path for f in files if any(h.context for h in f.hunks)] 

429 if not widened: 

430 return None 

431 return ( 

432 f"the diff carries context lines (in {', '.join(widened[:3])}), so its hunks may merge " 

433 "independent edits; check GIT_DIFF_OPTS and the diff settings in your git config" 

434 ) 

435 

436 

437@dataclass(frozen=True) 

438class Change: 

439 """One production change to revert: a label for the report and its patch.""" 

440 

441 label: str 

442 path: str 

443 #: A unified diff that ``git apply -R`` undoes on HEAD. 

444 patch: str 

445 #: Every hunk in it only adds lines — see :func:`judge` for why that matters. 

446 pure_addition: bool 

447 

448 

449@dataclass(frozen=True) 

450class Plan: 

451 """What the gate will revert, and what it had to leave out.""" 

452 

453 changes: tuple[Change, ...] 

454 #: Production files with no textual hunk (a binary file, or an empty file added or 

455 #: deleted): not checked. A mode-only change is a change of its own, and is checked. 

456 unrevertable: tuple[str, ...] 

457 #: Every path the diff touched, for the finding that says none was production. 

458 touched: tuple[str, ...] 

459 

460 

461def plan_changes( 

462 files: Sequence[FileDiff], *, tests: Sequence[str], paths: Sequence[str], unit: str 

463) -> Plan: 

464 """The production changes to revert, ordered by path. 

465 

466 Ordered here, not by git: ``diff.orderFile`` reorders git's output, and the order 

467 decides which changes ``max_changes`` reaches. Each change is reverted alone, so each 

468 must carry only itself: 

469 

470 * **A mode change is its own change** (``old mode``/``new mode``). Copied into every 

471 content hunk's patch, it was undone with each of them, and one test of the 

472 executable bit "caught" every hunk of the file. 

473 * **Added Python code is split at its top-level definitions** (:func:`_blocks`) — a 

474 hunk that only adds lines, and a new file's content, whatever ``unit`` says for a 

475 new file. Undone whole, three added functions where a test calls one read as 

476 "noticed" for all three; undone one at a time, each must be noticed on its own. A 

477 new file that is one block stays one change, which deletes it. Other languages, and 

478 added lines that do not parse on their own, are not split. 

479 * Otherwise one change per hunk (``unit: hunk``) or per file (``unit: file``); a 

480 deleted file is one change, which restores it. 

481 """ 

482 changes: list[Change] = [] 

483 unrevertable: list[str] = [] 

484 for f in sorted(files, key=lambda f: f.path): 

485 if not is_production(f.path, tests=tests, paths=paths): 

486 continue 

487 if f.mode is not None and f.status == "modified": 

488 old, new = f.mode 

489 mode_patch = "\n".join((f.header[0], f"old mode {old}", f"new mode {new}")) + "\n" 

490 changes.append(Change(f"{f.path} (mode {old} -> {new})", f.path, mode_patch, False)) 

491 if f.binary or not f.hunks: 

492 if f.binary or f.mode is None: 

493 unrevertable.append(f.path) 

494 continue 

495 header = f.content_header 

496 if f.status == "added": 

497 blocks = _blocks(f.hunks[0], f.path) if len(f.hunks) == 1 else [] 

498 if len(blocks) > 1: 

499 plain = (header[0], _old_side(header), *(h for h in header if h[:4] == "+++ ")) 

500 changes.extend(_block_change(f.path, plain, block) for block in blocks) 

501 else: 

502 changes.append( 

503 Change(f"{f.path} (new file)", f.path, _patch(header, f.hunks), True) 

504 ) 

505 continue 

506 if unit == UNIT_FILE or f.status == "deleted": 

507 suffix = " (deleted file)" if f.status == "deleted" else "" 

508 changes.append( 

509 Change( 

510 f"{f.path}{suffix}", 

511 f.path, 

512 _patch(header, f.hunks), 

513 all(h.pure_addition for h in f.hunks), 

514 ) 

515 ) 

516 continue 

517 for hunk in f.hunks: 

518 blocks = _blocks(hunk, f.path) if hunk.pure_addition else [] 

519 if len(blocks) > 1: 

520 changes.extend(_block_change(f.path, header, block) for block in blocks) 

521 continue 

522 label = f"{f.path} {hunk.header.split(' @@', 1)[0]} @@" 

523 changes.append(Change(label, f.path, _patch(header, (hunk,)), hunk.pure_addition)) 

524 return Plan(tuple(changes), tuple(unrevertable), tuple(f.path for f in files)) 

525 

526 

527#: Files whose added code :func:`_blocks` can split: it parses them. 

528_PYTHON_SUFFIXES = (".py", ".pyi") 

529_DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) 

530 

531 

532def _old_side(header: Sequence[str]) -> str: 

533 """A new file's ``--- a/x`` line, from its ``+++ b/x`` line as git wrote it (quoting and 

534 all), so a patch that removes part of the file names the file that stays.""" 

535 new = next(h for h in header if h[:4] == "+++ ") 

536 name = new[4:] 

537 return "--- " + ('"a/' + name[3:] if name.startswith('"b/') else "a/" + name[2:]) 

538 

539 

540def _blocks(hunk: Hunk, path: str) -> list[tuple[int, tuple[str, ...]]]: 

541 """A pure-addition hunk's lines split at its top-level definitions, as ``(first line, lines)``. 

542 

543 Only Python is split, and only when the added lines parse on their own 

544 (:func:`_block_starts`): a boundary read from the text alone was wrong both ways — two 

545 functions with no blank line between them stayed one change, so a test of one vouched 

546 for the other, and a blank line inside a string literal split it, so each half's revert 

547 was a syntax error. ``[]`` (keep the hunk whole) otherwise, and for a header that cannot 

548 be read. Lines between blocks ride with the block before them (leading ones with the 

549 first); a ``\\ No newline`` marker stays with its line. 

550 """ 

551 match = _HUNK_RE.match(hunk.header) 

552 if not match or not path.endswith(_PYTHON_SUFFIXES): 

553 return [] 

554 starts = _block_starts([line[1:] for line in hunk.lines if line.startswith("+")]) 

555 if not starts: 

556 return [] 

557 first = int(match.group(3)) 

558 blocks: list[tuple[int, list[str]]] = [] 

559 offset = 0 

560 for line in hunk.lines: 

561 if line.startswith("+"): 

562 if not blocks or offset in starts: 

563 blocks.append((first + offset, [])) 

564 offset += 1 

565 blocks[-1][1].append(line) 

566 return [(start, tuple(lines)) for start, lines in blocks] 

567 

568 

569def _block_starts(added: Sequence[str]) -> set[int]: 

570 """The 0-based added lines where a new block starts; empty when there is one block. 

571 

572 The lines are dedented by their common leading whitespace and parsed. Each ``def`` and 

573 ``class`` (from its first decorator) is a block, and so is each run of other statements 

574 between them. Empty when the lines do not parse on their own — part of an expression, 

575 a string whose continuation sits left of the rest — or do not share one indent. 

576 """ 

577 texts = [text for text in added if text.strip()] 

578 if not texts: 

579 return set() 

580 width = min(len(text) - len(text.lstrip()) for text in texts) 

581 indent = texts[0][:width] 

582 if any(not text.startswith(indent) for text in texts): 

583 return set() 

584 source = "\n".join(text[width:] if text.strip() else "" for text in added) + "\n" 

585 try: 

586 tree = ast.parse(source) 

587 except (SyntaxError, ValueError, RecursionError): 

588 return set() 

589 starts: set[int] = set() 

590 previous: bool | None = None 

591 for node in tree.body: 

592 definition = isinstance(node, _DEFINITIONS) 

593 if previous is not None and (definition or previous): 

594 decorators = getattr(node, "decorator_list", []) 

595 starts.add(min([node.lineno, *(d.lineno for d in decorators)]) - 1) 

596 previous = definition 

597 return starts 

598 

599 

600def _block_change(path: str, header: Sequence[str], block: tuple[int, tuple[str, ...]]) -> Change: 

601 """One block of added lines as its own change: a patch that removes just those lines.""" 

602 first, lines = block 

603 count = sum(1 for line in lines if line.startswith("+")) 

604 hunk_header = f"@@ -{first - 1},0 +{first},{count} @@" 

605 patch = "\n".join((*header, hunk_header, *lines)) + "\n" 

606 return Change(f"{path} {hunk_header}", path, patch, True) 

607 

608 

609def _patch(header: Sequence[str], hunks: Sequence[Hunk]) -> str: 

610 lines = list(header) 

611 for hunk in hunks: 

612 lines.append(hunk.header) 

613 lines.extend(hunk.lines) 

614 return "\n".join(lines) + "\n" 

615 

616 

617# --- what a change looks like ------------------------------------------------------ 

618# 

619# Nothing here skips a run. Every production change in scope is reverted and tested: an 

620# earlier version skipped changes it judged "inert" (comments, docstrings, formatting), 

621# and each proof of inertness had a hole — a Go ``//go:embed`` directive, a Python 

622# ``# coding:`` cookie, a comment that moves ``__LINE__`` or ``f_lineno`` — because "no 

623# test can observe this change" is not provable in general (#1289 review rounds). 

624 

625#: Leading text that makes a line **look** like a comment in some language. A wording 

626#: heuristic only (:func:`looks_comment_only`): it never decides whether a change is run. 

627_COMMENT_LOOKS: tuple[str, ...] = ("#", "//", "/*", "*/", "* ", "--", ";", "%") 

628 

629 

630def _changed_lines(patch: str) -> list[str]: 

631 """The ``+``/``-`` lines of a change's hunks, without their marker.""" 

632 _header, _sep, body = patch.partition("\n@@") 

633 return [line[1:] for line in body.split("\n") if line[:1] in ("+", "-")] 

634 

635 

636def looks_comment_only(change: Change) -> bool: 

637 """Does every changed line look blank or like a comment? **Wording only.** 

638 

639 Used to explain a blocking "no test notices this change" finding, never to skip a 

640 run: a comment can still change behaviour. 

641 """ 

642 lines = _changed_lines(change.patch) 

643 return bool(lines) and all( 

644 not line.strip() or line.strip() == "*" or line.strip().startswith(_COMMENT_LOOKS) 

645 for line in lines 

646 ) 

647 

648 

649_NAMES = r"[A-Za-z_]\w*(?:\s+as\s+[A-Za-z_]\w*)?(?:\s*,\s*[A-Za-z_]\w*(?:\s+as\s+[A-Za-z_]\w*)?)*" 

650_DOTTED = r"[A-Za-z_][\w.]*(?:\s+as\s+[A-Za-z_]\w*)?" 

651#: One complete import statement on one line, and nothing else on it — no trailing 

652#: comment, no ``;``, no open parenthesis left for a continuation line. 

653_IMPORT_LINE = re.compile( 

654 rf"^\s*(?:import\s+{_DOTTED}(?:\s*,\s*{_DOTTED})*" 

655 rf"|from\s+(?:\.+[\w.]*|[A-Za-z_][\w.]*)\s+import\s+" 

656 rf"(?:\*|{_NAMES}|\(\s*{_NAMES}\s*,?\s*\)))\s*$" 

657) 

658 

659 

660def imports_only(change: Change) -> bool: 

661 """Is ``change`` a Python change whose every changed line is a whole import statement? 

662 

663 Read from the change's **own** lines, never from a comparison of the two files: an 

664 AST cannot see an encoding cookie, and ``# coding: latin-1`` beside an import once 

665 passed as "imports only" while it changed what a string literal decodes to. Blank 

666 lines are ignored; a comment, a cookie, a continuation line of a multi-line import or 

667 anything else makes the answer ``False`` — that change's errors then block, which is 

668 the safe side. It classifies a result *after* its run; it never skips one. 

669 """ 

670 if posixpath.splitext(change.path)[1].lower() not in (".py", ".pyi"): 

671 return False 

672 lines = [line for line in _changed_lines(change.patch) if line.strip()] 

673 return bool(lines) and all(_IMPORT_LINE.match(line) for line in lines) 

674 

675 

676#: The exceptions that say "a name the tests reach is gone" — all a revert of pure 

677#: added code (or of an imported name) can produce. ``AttributeError`` counts only for a 

678#: *module* or *class* attribute: ``'NoneType' object has no attribute`` is behaviour. 

679_MISSING_NAME = re.compile( 

680 r"^(?:NameError|UnboundLocalError|ImportError|ModuleNotFoundError)\b" 

681 r"|^AttributeError: (?:(?:partially initialized )?module '[^']*'|type object '[^']*') " 

682 r"has no attribute\b" 

683) 

684#: The start of a Python traceback; its exception line is the first unindented line after it. 

685_TRACEBACK = "Traceback (most recent call last):" 

686#: A pytest short-summary line's reason (`` - <Exception>: message``), any exception name. 

687_PYTEST_REASON = re.compile(r"^(?:FAILED|ERROR) .*? - ([A-Za-z_][\w.]*(?::.*)?)$", re.MULTILINE) 

688 

689 

690def _raised(output: str) -> list[str]: 

691 """Every exception the output reports, as ``Name: message`` with the module dropped. 

692 

693 Read from each traceback's final line — whatever the exception is called, so a 

694 custom ``Boom`` or a ``StopIteration`` is seen too — and from pytest's short summary. 

695 """ 

696 found: list[str] = [] 

697 lines = output.split("\n") 

698 for i, line in enumerate(lines): 

699 if line.strip() != _TRACEBACK: 

700 continue 

701 rest = (text for text in lines[i + 1 :] if text.strip() and not text[:1].isspace()) 

702 found.append(next(rest, "")) 

703 found.extend(match.group(1) for match in _PYTEST_REASON.finditer(output)) 

704 return [ 

705 name.rsplit(".", 1)[-1] + sep + message 

706 for name, sep, message in (text.partition(":") for text in found) 

707 ] 

708 

709 

710def missing_names_only(output: str) -> bool: 

711 """Does every exception the run reports say only that a name is missing? 

712 

713 The evidence a lenient reading needs: the error-only result of undoing an addition is 

714 a ``nit`` only when the errors are the ones removing code can cause. A ``KeyError``, a 

715 ``TypeError``, an attribute missing from an *instance*, any exception keel cannot 

716 name, and output with no traceback at all are behaviour, and block. 

717 

718 **Every** failure the run counts must be described: a summary of ``2 failed`` whose 

719 short summary names one ``NameError`` says nothing about the other, which could be an 

720 assertion or a crash (#1289 review round 9). So pytest's undescribed failures 

721 (:attr:`Tally.unclassified`) refuse it, and so does a count of failures and errors 

722 larger than the exceptions keel read. 

723 """ 

724 found = _raised(output) 

725 tally = read_output(output) 

726 return ( 

727 bool(found) 

728 and not tally.unclassified 

729 and len(found) >= tally.errors + tally.assertions 

730 and all(_MISSING_NAME.match(text) for text in found) 

731 ) 

732 

733 

734# --- reading a test run -------------------------------------------------------------- 

735 

736_UNITTEST_RAN = re.compile(r"^Ran (\d+) tests? in ", re.MULTILINE) 

737_UNITTEST_FAILED = re.compile(r"^FAILED \(([^)]*)\)\s*$", re.MULTILINE) 

738_PYTEST_SUMMARY = re.compile( 

739 r"^=*\s*((?:\d+ [a-z]+(?:, )?)+) in [\d.]+s\b.*$|^=*\s*no tests ran in [\d.]+s\b.*$", 

740 re.MULTILINE, 

741) 

742_PYTEST_COUNT = re.compile(r"(\d+) ([a-z]+)") 

743#: A pytest short-summary line. The node id never starts with ``(``, which is what keeps 

744#: unittest's own ``FAILED (failures=1)`` from being read as a failed pytest test. 

745#: 

746#: The node id may contain spaces — a parametrized id (``test_v[hello world]``) or a file 

747#: name — so it is read as: a bracketed id up to its ``]``, else one word, else the 

748#: shortest text before `` - ``; the reason is what follows `` - `` and may be absent. 

749_PYTEST_LINE = re.compile(r"^(FAILED|ERROR) (?!\()(\S*?\[.*?\]|\S+|.+?)(?: - (.*))?$", re.MULTILINE) 

750#: A unittest failure section's header: ``FAIL: test_x (pkg.T.test_x)`` is an assertion, 

751#: ``ERROR: …`` anything else. What the differential reading compares. 

752_UNITTEST_HEADER = re.compile(r"^(FAIL|ERROR): (.+?)\s*$", re.MULTILINE) 

753#: A pytest short-summary reason that is an assertion: a rewritten ``assert``, an 

754#: ``AssertionError`` (unittest-style assertions under pytest), or ``pytest.fail``. 

755#: ``Failed: Timeout`` is excluded: pytest-timeout reports a timed-out test that way, and 

756#: a hang is not an assertion. 

757_ASSERTION_REASON = re.compile(r"^(?:assert\b|AssertionError\b|Failed:(?! Timeout\b))") 

758 

759 

760@dataclass(frozen=True) 

761class Tally: 

762 """What a test run's output says happened.""" 

763 

764 #: The output carries a unittest or pytest summary at all. 

765 recognized: bool = False 

766 #: Tests that ran. 

767 ran: int = 0 

768 #: Tests that failed as an assertion. 

769 assertions: int = 0 

770 #: Tests that failed any other way: an exception, an import or collection error, a 

771 #: strict unexpected success. 

772 errors: int = 0 

773 #: pytest counted a failure its short summary does not describe (``-rN``, truncation). 

774 unclassified: int = 0 

775 #: The ids of the tests that failed as an assertion, where the runner names them. 

776 assertion_ids: frozenset[str] = frozenset() 

777 #: The ids of every failing test — assertion or not — where the runner names them. 

778 failure_ids: frozenset[str] = frozenset() 

779 

780 @property 

781 def failed(self) -> bool: 

782 """Does the output report any failing or erroring test at all?""" 

783 return bool(self.assertions or self.errors or self.unclassified or self.failure_ids) 

784 

785 

786def read_output(output: str) -> Tally: 

787 """Read unittest's and pytest's summaries out of a test command's combined output. 

788 

789 **unittest** separates the two cleanly: ``failures`` are the test's 

790 ``failureException`` (an ``AssertionError``), ``errors`` are anything else, a test 

791 module that failed to import included. 

792 

793 **pytest** does not: its ``failed`` count includes a ``NameError`` raised in the test 

794 body. The short test summary (``-rfE``, pytest's default since 6.0) names each 

795 failure's exception, and only an assertion reason counts; a failure the summary does 

796 not describe is *unclassified*, never an assertion. 

797 

798 Every summary in the output is summed, so a command running two suites is read whole. 

799 """ 

800 ran = assertions = errors = unclassified = 0 

801 recognized = False 

802 for match in _UNITTEST_RAN.finditer(output): 

803 recognized = True 

804 ran += int(match.group(1)) 

805 for match in _UNITTEST_FAILED.finditer(output): 

806 for part in match.group(1).split(","): 

807 key, _, value = part.strip().partition("=") 

808 count = int(value) if value.isdigit() else 0 

809 if key == "failures": 

810 assertions += count 

811 elif key in ("errors", "unexpected successes"): 

812 errors += count 

813 pytest_failed = 0 

814 for match in _PYTEST_SUMMARY.finditer(output): 

815 recognized = True 

816 for count, word in _PYTEST_COUNT.findall(match.group(1) or ""): 

817 if word in ("passed", "failed", "xfailed", "xpassed"): 

818 ran += int(count) 

819 if word == "failed": 

820 pytest_failed += int(count) 

821 elif word in ("error", "errors"): 

822 errors += int(count) 

823 described = 0 

824 asserted: set[str] = set() 

825 failing: set[str] = set() 

826 for kind, test_id in _UNITTEST_HEADER.findall(output): 

827 failing.add(test_id) 

828 if kind == "FAIL": 

829 asserted.add(test_id) 

830 for match in _PYTEST_LINE.finditer(output): 

831 failing.add(match.group(2)) 

832 if match.group(1) != "FAILED": 

833 continue # ERROR lines are already in the summary's error count 

834 described += 1 

835 if _ASSERTION_REASON.match(match.group(3) or ""): 

836 assertions += 1 

837 asserted.add(match.group(2)) 

838 else: 

839 errors += 1 

840 unclassified = max(0, pytest_failed - described) 

841 return Tally( 

842 recognized, 

843 ran, 

844 assertions, 

845 errors, 

846 unclassified, 

847 frozenset(asserted), 

848 frozenset(failing), 

849 ) 

850 

851 

852def classify( 

853 *, exit_ok: bool, timed_out: bool, output: str, baseline_failed: frozenset[str] = frozenset() 

854) -> tuple[str, str]: 

855 """One reverted run -> ``(result, why)``. Only :data:`CAUGHT` is a pass. 

856 

857 The exit code decides pass/fail; the output decides *how* it failed. A test that 

858 errored is not a test that asserted — reverting half a fix can raise ``NameError`` 

859 in every test that imports it, and that proves the import, not the behaviour. 

860 

861 **Differential.** ``baseline_failed`` names the tests that failed without the revert. 

862 Where the runner names the assertion failures, the change is caught only by one 

863 *not* among them, so a failure that was already there cannot certify it. 

864 (:func:`baseline_problem` already refuses a baseline with any failure; this holds the 

865 rule on its own too.) 

866 """ 

867 if timed_out: 

868 return TIMED_OUT, "the test command timed out with the change reverted" 

869 if exit_ok: 

870 return UNNOTICED, "the test command passed with the change reverted" 

871 tally = read_output(output) 

872 if tally.assertion_ids and tally.assertion_ids <= baseline_failed: 

873 return UNNOTICED, ( 

874 "the only tests that failed as an assertion also fail without the revert" 

875 ) 

876 if tally.assertions: 

877 return CAUGHT, f"{tally.assertions} test(s) failed as an assertion" 

878 if not tally.recognized: 

879 return UNREADABLE, ( 

880 "the test command failed, and its output carries no unittest or pytest summary " 

881 "to say whether a test failed" 

882 ) 

883 if tally.errors: 

884 return ERRORED, f"{tally.errors} test(s) errored and none failed as an assertion" 

885 if tally.unclassified: 

886 return UNREADABLE, ( 

887 f"pytest reported {tally.unclassified} failure(s) its short test summary does not " 

888 "describe, so an assertion cannot be told from an error (keep pytest's -rf)" 

889 ) 

890 return UNREADABLE, "the test command failed, but no test did" 

891 

892 

893def baseline_problem(*, exit_ok: bool, timed_out: bool, output: str) -> str | None: 

894 """Why the unreverted run cannot anchor the check, or ``None`` when it can. 

895 

896 A failure after a revert means something only if the same command passes — and is 

897 readable — on HEAD itself, in the same kind of clean checkout. 

898 """ 

899 if timed_out: 

900 return "the test command timed out on a clean checkout of HEAD" 

901 if not exit_ok: 

902 return ( 

903 "the test command fails on a clean checkout of HEAD, so a failure with a change " 

904 "reverted would prove nothing" 

905 ) 

906 tally = read_output(output) 

907 if not tally.recognized: 

908 return ( 

909 "the test command's output carries no unittest or pytest summary; revert-check " 

910 "reads those two to tell an assertion from an error" 

911 ) 

912 if tally.ran == 0: 

913 return "the test command ran no tests on a clean checkout of HEAD" 

914 if tally.failed: 

915 # `suite1; suite2` exits with suite2's status: a suite already failing an 

916 # assertion would otherwise lend that assertion to every revert. 

917 return ( 

918 "the test command exits 0 on a clean checkout of HEAD but its output reports " 

919 "failing tests, so a failure with a change reverted would prove nothing" 

920 ) 

921 return None 

922 

923 

924# --- the bounded loop ---------------------------------------------------------------- 

925 

926 

927@dataclass(frozen=True) 

928class RunResult: 

929 """One execution of the test command, as the I/O layer observed it.""" 

930 

931 exit_ok: bool = False 

932 timed_out: bool = False 

933 output: str = "" 

934 

935 

936@dataclass(frozen=True) 

937class Reverted: 

938 """One change undone in the scratch tree, as the I/O layer observed it.""" 

939 

940 #: The tree was reset and the reverse patch applied. 

941 applied: bool 

942 

943 

944@dataclass(frozen=True) 

945class ChangeResult: 

946 change: Change 

947 result: str 

948 why: str 

949 #: The change only adds code or import lines (:attr:`Change.pure_addition`, or 

950 #: :func:`imports_only`) **and** every error its run reports is a missing name 

951 #: (:func:`missing_names_only`) — the one error-only result :func:`judge` reads as a 

952 #: ``nit``. 

953 adds_only: bool = False 

954 

955 

956@dataclass(frozen=True) 

957class Report: 

958 """What :func:`execute` observed.""" 

959 

960 #: Set when the baseline run cannot anchor the check; nothing was reverted then. 

961 baseline: str | None 

962 results: tuple[ChangeResult, ...] = () 

963 #: Changes left unchecked by ``max_changes`` or ``budget_s``, with the reason. 

964 not_checked: tuple[tuple[Change, str], ...] = () 

965 

966 

967#: ``revert(change)``: reset the scratch tree to ``HEAD`` and undo ``change`` in it. 

968Reverter = Callable[[Change], Reverted] 

969#: ``test(timeout_s)``: run the test command in the scratch tree as it stands. 

970Tester = Callable[[int], RunResult] 

971 

972 

973def execute( 

974 changes: Sequence[Change], 

975 settings: Settings, 

976 *, 

977 revert: Reverter, 

978 test: Tester, 

979 clock: Callable[[], float], 

980) -> Report: 

981 """Run the baseline, then revert each change alone, within the cost bounds. 

982 

983 ``max_changes`` caps how many changes are reverted; ``budget_s`` caps the wall clock 

984 of the baseline and every revert together, and each run's limit is the smaller of 

985 ``knobs.gate_timeout_s`` and what is left of it. A change either bound leaves out is 

986 reported **not checked** — never as caught. Every change that is reverted is tested: 

987 nothing is skipped for looking harmless. 

988 """ 

989 start = clock() 

990 

991 def left() -> float: 

992 return settings.budget_s - (clock() - start) 

993 

994 def limit(remaining: float) -> int: 

995 return max(1, min(settings.run_timeout_s, int(remaining))) 

996 

997 base = test(limit(left())) 

998 problem = baseline_problem(exit_ok=base.exit_ok, timed_out=base.timed_out, output=base.output) 

999 if problem is not None: 

1000 return Report(problem) 

1001 baseline_failed = read_output(base.output).failure_ids 

1002 results: list[ChangeResult] = [] 

1003 skipped: list[tuple[Change, str]] = [] 

1004 for index, change in enumerate(changes): 

1005 if index >= settings.max_changes: 

1006 skipped.append( 

1007 (change, f"over knobs.revert_check.max_changes ({settings.max_changes})") 

1008 ) 

1009 continue 

1010 remaining = left() 

1011 if remaining < 1: 

1012 skipped.append( 

1013 (change, f"the knobs.revert_check.budget_s budget ({settings.budget_s}s) ran out") 

1014 ) 

1015 continue 

1016 undone = revert(change) 

1017 if not undone.applied: 

1018 results.append( 

1019 ChangeResult(change, NOT_APPLIED, "git apply -R could not undo it on HEAD") 

1020 ) 

1021 continue 

1022 # Re-read the clock: resetting, cleaning, applying and reading took time too, and a 

1023 # run must never start with a limit the budget no longer has. 

1024 remaining = left() 

1025 if remaining < 1: 

1026 skipped.append( 

1027 (change, f"the knobs.revert_check.budget_s budget ({settings.budget_s}s) ran out") 

1028 ) 

1029 continue 

1030 outcome = test(limit(remaining)) 

1031 result, why = classify( 

1032 exit_ok=outcome.exit_ok, 

1033 timed_out=outcome.timed_out, 

1034 output=outcome.output, 

1035 baseline_failed=baseline_failed, 

1036 ) 

1037 adds_only = (change.pure_addition or imports_only(change)) and missing_names_only( 

1038 outcome.output 

1039 ) 

1040 results.append(ChangeResult(change, result, why, adds_only)) 

1041 return Report(None, tuple(results), tuple(skipped)) 

1042 

1043 

1044# --- the verdict --------------------------------------------------------------------- 

1045 

1046 

1047@dataclass(frozen=True) 

1048class Verdict: 

1049 """The gate's outcome, in :class:`keel.gates.GateOutcome` terms.""" 

1050 

1051 ok: bool 

1052 findings: tuple[Finding, ...] 

1053 #: The gate cannot judge — only the project's config or environment can fix it. 

1054 unconfigured: bool = False 

1055 #: Nothing to check: the diff has no production change. Reported ``SKIPPED``. 

1056 skipped: bool = False 

1057 

1058 

1059def cannot_judge(why: str) -> Verdict: 

1060 """A verdict for a check that could not run: failed, never a pass (#1364).""" 

1061 return Verdict(False, (Finding(_BLOCK, f"cannot judge: {why}", GATE_ID),), unconfigured=True) 

1062 

1063 

1064#: Why the gate cannot judge a plan with no guard or test gate beside it (#1289 review). 

1065PLANNED_ALONE = ( 

1066 "revert-check is not a test gate: it reverts each change against a suite the guard and " 

1067 "test gates proved green, and none is planned — list build (with knobs.build_gate_cmd) " 

1068 "beside it in gates:" 

1069) 

1070 

1071 

1072def precheck(settings: Settings, *, tests: Sequence[str], gates_green: bool) -> Verdict | None: 

1073 """The verdict when the check must not start, else ``None``. 

1074 

1075 Order matters: a missing command or test layout is a config problem no run can fix, 

1076 so it is reported before the red-gates case an implementer can. 

1077 """ 

1078 if settings.cmd is None: 

1079 return cannot_judge( 

1080 "no test command: set knobs.revert_check.cmd or knobs.build_gate_cmd " 

1081 "in .keel/project.yaml" 

1082 ) 

1083 if not tests: 

1084 return cannot_judge( 

1085 "revert-check cannot tell tests from production: declare where the tests live " 

1086 "in policy_pack.test_groups.<group>.test_paths" 

1087 ) 

1088 if not gates_green: 

1089 return Verdict( 

1090 False, 

1091 ( 

1092 Finding( 

1093 _BLOCK, 

1094 "not run: the guard and test gates are red, and a revert check against a " 

1095 "red suite cannot tell which failures the revert caused", 

1096 GATE_ID, 

1097 ), 

1098 ), 

1099 ) 

1100 return None 

1101 

1102 

1103def judge(plan: Plan, report: Report | None) -> Verdict: 

1104 """The gate's verdict: every production change must make a test fail as an assertion. 

1105 

1106 * **unnoticed**, **timed out**, **unreadable**, **not applied** and **not checked** 

1107 each block, naming the change: a change the gate did not see fail is not a pass. 

1108 * **errored** — and **unreadable**, where the suite crashed before its summary — block, 

1109 except for a change that only *adds* lines or only changes import lines **and** 

1110 whose run reported nothing but missing names (``NameError``, ``ImportError``, a 

1111 module or class attribute). Reverting such an addition removes a name, and a test 

1112 that calls it can then only error: no assertion can fail against code that is not 

1113 there. The error does prove a test depends on it, so it is a ``nit``. Any other 

1114 error — a ``KeyError``, a ``TypeError``, an instance attribute — is behaviour a test 

1115 should assert on, and blocks, as does any error after reverting a modification. 

1116 * A production file with no textual hunk (binary, or an empty file added or deleted) 

1117 blocks as **not checked**: nothing could be reverted, and the gate never certifies 

1118 what it did not check. 

1119 * A diff with no production change is ``SKIPPED`` — judged, with nothing to check. 

1120 

1121 ``report`` is ``None`` only when there was nothing to execute. 

1122 """ 

1123 findings: list[Finding] = [] 

1124 for path in plan.unrevertable: 

1125 findings.append( 

1126 Finding( 

1127 _BLOCK, 

1128 f"{path}: not checked — no textual hunk to revert (a binary file, or an empty file " 

1129 "added or deleted)", 

1130 GATE_ID, 

1131 ) 

1132 ) 

1133 if report is None: 

1134 if findings: 

1135 return Verdict(False, tuple(findings)) 

1136 touched = len(plan.touched) 

1137 return Verdict( 

1138 True, 

1139 ( 

1140 Finding( 

1141 "nit", 

1142 f"nothing to revert: none of the {touched} changed file(s) is production " 

1143 "code (knobs.revert_check.paths, or a source suffix outside the test paths)", 

1144 GATE_ID, 

1145 ), 

1146 ), 

1147 skipped=True, 

1148 ) 

1149 if report.baseline is not None: 

1150 return cannot_judge(report.baseline) 

1151 caught = 0 

1152 for item in report.results: 

1153 label = item.change.label 

1154 if item.result == CAUGHT: 

1155 caught += 1 

1156 elif item.result in (ERRORED, UNREADABLE) and item.adds_only: 

1157 findings.append( 

1158 Finding( 

1159 "nit", 

1160 f"{label}: noticed only as a missing name — {item.why}; the change only " 

1161 "adds code or import lines, so no assertion can fail without it", 

1162 GATE_ID, 

1163 ) 

1164 ) 

1165 elif item.result == UNNOTICED: 

1166 hint = ( 

1167 "; it looks comment-only, and keel tests every change, because a comment can " 

1168 "still change behaviour (encoding cookies, compiler directives, line " 

1169 "numbers) — add a test that notices it, or scope such files out with " 

1170 "knobs.revert_check.paths if you do not want them checked" 

1171 if looks_comment_only(item.change) 

1172 else "" 

1173 ) 

1174 findings.append( 

1175 Finding(_BLOCK, f"{label}: no test notices this change — {item.why}{hint}", GATE_ID) 

1176 ) 

1177 else: 

1178 findings.append( 

1179 Finding(_BLOCK, f"{label}: no test failed as an assertion — {item.why}", GATE_ID) 

1180 ) 

1181 for change, why in report.not_checked: 

1182 findings.append(Finding(_BLOCK, f"{change.label}: not checked — {why}", GATE_ID)) 

1183 total = len(report.results) + len(report.not_checked) 

1184 summary = ( 

1185 f"{caught} of {total} production change(s) made a test fail as an assertion when " 

1186 "reverted alone" 

1187 ) 

1188 findings.append(Finding("nit", summary, GATE_ID)) 

1189 blocked = any(f.severity == _BLOCK for f in findings) 

1190 return Verdict(not blocked, tuple(findings))