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

119 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +0000

1"""The deterministic ship decisions — keel's value-add as pure functions. 

2 

3The agentic steps and the git/gh plumbing live in the adapter + I/O layer; the 

4*decisions* (how many reviewers, whether to merge / defer / block, whether to keep 

5fixing) are pure and live here, so they are reproducible and fully unit-tested. 

6""" 

7 

8from __future__ import annotations 

9 

10from collections.abc import Sequence 

11from dataclasses import dataclass 

12from typing import Any 

13 

14from . import classify 

15from .findings import Verdict 

16from .window import is_merge_open 

17 

18#: Hard cap on review→fix rounds (matches ship's budget). 

19MAX_FIX_ROUNDS = 3 

20 

21#: GitHub check-rollup conclusions that count as "not failing". 

22CI_OK_STATES = frozenset({"SUCCESS", "NEUTRAL", "SKIPPED"}) 

23 

24POSTING_MODES = frozenset({"inline", "summary"}) 

25 

26# A cross-vendor jury needs at least this many distinct vendors to gate. Below it 

27# the panel cannot produce cross-vendor consensus, so the verdict is advisory — 

28# and a run where no agent produced output counts as zero, which is how "a jury 

29# that did not complete cleanly never gates" falls out of the same comparison. 

30MINIMUM_JURY_VENDORS = 2 

31 

32REVIEW_FOCUS_A = ( 

33 "logic correctness", 

34 "null safety", 

35 "language interop", 

36) 

37REVIEW_FOCUS_B = ( 

38 "platform compatibility", 

39 "lifecycle safety", 

40 "API compatibility", 

41 "threading", 

42) 

43REVIEW_FOCUS_C = ( 

44 "test coverage", 

45 "docs gate", 

46 "scope creep", 

47 "CI prediction", 

48 "security", 

49) 

50 

51 

52def reviewer_count(tier: int) -> int: 

53 """Reviewers for a risk tier: TIER-3→3, TIER-2→2, TIER-1→1 (default 2).""" 

54 return {3: 3, 2: 2, 1: 1}.get(tier, 2) 

55 

56 

57def reviewer_focuses(count: int) -> tuple[dict[str, Any], ...]: 

58 """Focus coverage for each reviewer slot. Lower counts merge focus; none are dropped.""" 

59 if count <= 1: 

60 return ({ 

61 "slot": "A", 

62 "focus": list(REVIEW_FOCUS_A + REVIEW_FOCUS_B + REVIEW_FOCUS_C), 

63 "merged_from": ["A", "B", "C"], 

64 },) 

65 if count == 2: 

66 return ( 

67 { 

68 "slot": "A", 

69 "focus": list(REVIEW_FOCUS_A + REVIEW_FOCUS_B), 

70 "merged_from": ["A", "B"], 

71 }, 

72 { 

73 "slot": "C", 

74 "focus": list(REVIEW_FOCUS_C), 

75 "merged_from": ["C"], 

76 }, 

77 ) 

78 return ( 

79 {"slot": "A", "focus": list(REVIEW_FOCUS_A), "merged_from": ["A"]}, 

80 {"slot": "B", "focus": list(REVIEW_FOCUS_B), "merged_from": ["B"]}, 

81 {"slot": "C", "focus": list(REVIEW_FOCUS_C), "merged_from": ["C"]}, 

82 ) 

83 

84 

85def resolve_jury( 

86 *, 

87 tier: int | None, 

88 gates: tuple[str, ...] = (), 

89 jury: bool = False, 

90 no_jury: bool = False, 

91 jury_advisory: bool = False, 

92 participating_vendors: int | None = None, 

93) -> dict[str, Any]: 

94 """Resolve the cross-vendor jury mode using ship flag precedence. 

95 

96 ``participating_vendors`` is the count of distinct vendors that actually took 

97 part in the panel. Below :data:`MINIMUM_JURY_VENDORS` a gating mode is 

98 downgraded to advisory, because a panel that small cannot produce 

99 cross-vendor consensus — and a run where no agent returned output is simply 

100 zero, so "a jury that did not complete cleanly never gates" needs no separate 

101 branch. ``None`` means the panel is not known yet (planning, ``keel plan``, 

102 any caller resolving the contract before s8 runs), and leaves the mode alone. 

103 

104 The downgrade must live here rather than in adapter prose: the evidence gate 

105 derives its ``jury-verdict`` requirement from this ``mode``, so a mode that 

106 ignores the real panel makes the gate demand a verdict the jury step would 

107 decline to treat as gating. 

108 """ 

109 if no_jury: 

110 enabled = False 

111 reason = "--no-jury" 

112 elif jury: 

113 enabled = True 

114 reason = "--jury" 

115 elif tier == 3: 

116 enabled = True 

117 reason = "tier-3 auto" 

118 else: 

119 enabled = False 

120 reason = "default" 

121 mode = "off" if not enabled else ("advisory" if jury_advisory else "gating") 

122 downgraded = ( 

123 mode == "gating" 

124 and participating_vendors is not None 

125 and participating_vendors < MINIMUM_JURY_VENDORS 

126 ) 

127 if downgraded: 

128 mode = "advisory" 

129 reason = ( 

130 f"{reason}; downgraded to advisory " 

131 f"({participating_vendors} participating vendor(s), " 

132 f"minimum {MINIMUM_JURY_VENDORS})" 

133 ) 

134 return { 

135 "enabled": enabled, 

136 "mode": mode, 

137 "reason": reason, 

138 "configured_gate": "jury" in gates, 

139 "fail_soft": True, 

140 "minimum_vendors": MINIMUM_JURY_VENDORS, 

141 "participating_vendors": participating_vendors, 

142 "downgraded": downgraded, 

143 "verified_consensus_gates": enabled and mode == "gating", 

144 "severity_policy": { 

145 "critical": "block", 

146 "major": "block", 

147 "minor": "gated-suggestion", 

148 "nit": "advisory", 

149 }, 

150 } 

151 

152 

153def resolve_review_contract( 

154 *, 

155 tier: int | None, 

156 reviewer_override: int | None = None, 

157 review_comments: str = "inline", 

158 gates: tuple[str, ...] = (), 

159 policy_pack: dict[str, Any] | None = None, 

160 jury: bool = False, 

161 no_jury: bool = False, 

162 jury_advisory: bool = False, 

163 require_distinct_vendors: bool = False, 

164 jury_participating_vendors: int | None = None, 

165) -> dict[str, Any]: 

166 """Machine-readable review, jury, test, and merge-gate plan for ship-like flows.""" 

167 if reviewer_override is not None and reviewer_override not in {1, 2, 3}: 

168 raise ValueError("reviewer_override must be one of 1, 2, or 3") 

169 if review_comments not in POSTING_MODES: 

170 raise ValueError("review_comments must be 'inline' or 'summary'") 

171 count = reviewer_override if reviewer_override is not None else reviewer_count(tier or 2) 

172 source = ( 

173 "override" if reviewer_override is not None 

174 else ("risk-tier" if tier is not None else "unresolved") 

175 ) 

176 pack = policy_pack or {} 

177 review_policy = pack.get("review", {}) if isinstance(pack.get("review", {}), dict) else {} 

178 return { 

179 "reviewers": { 

180 "count": count, 

181 "source": source, 

182 "tier": tier, 

183 "independent": True, 

184 "self_review_counts_toward_lgtm": False, 

185 "minimum_lgtm": count, 

186 "require_distinct_vendors": bool(require_distinct_vendors), 

187 "orchestrator_owns_writes": True, 

188 "focuses": list(reviewer_focuses(count)), 

189 "project_additions": list(review_policy.get("additions", [])), 

190 "required_sections": list(review_policy.get("required_sections", [])), 

191 }, 

192 "posting": { 

193 "mode": review_comments, 

194 "inline_default": True, 

195 "per_reviewer_inline_fallback": "summary", 

196 "summary_mode": review_comments == "summary", 

197 }, 

198 "jury": resolve_jury( 

199 tier=tier, 

200 gates=gates, 

201 jury=jury, 

202 no_jury=no_jury, 

203 jury_advisory=jury_advisory, 

204 participating_vendors=jury_participating_vendors, 

205 ), 

206 "finding_policy": { 

207 "critical": "block", 

208 "major": "block", 

209 "minor": "gated-suggestion", 

210 "nit": "advisory", 

211 "suggestions_require_fix_or_explicit_deferral": True, 

212 "parser_source": "reviewer-returned-findings", 

213 }, 

214 "fixloop": { 

215 "max_rounds": MAX_FIX_ROUNDS, 

216 "blocker_rerun": "full-review", 

217 "suggestion_only_rerun": "narrowed-originating-focus", 

218 }, 

219 "ci": { 

220 "failure_before_pending": True, 

221 "empty_check_set_allowed_for_docs_only": True, 

222 "retry_budget": 3, 

223 }, 

224 "test_gates": { 

225 "configured_gates": list(gates), 

226 "no_jury_preserves_review_and_test_gates": True, 

227 }, 

228 "merge_gate": { 

229 "merge_window_applies_to": "literal-merge-only", 

230 "merge_lock_scope": "literal-merge-only", 

231 "final_mergeability_recheck_inside_lock": True, 

232 "hotfix_bypasses_window_only": True, 

233 "hotfix_never_bypasses_findings_or_ci": True, 

234 "pr_merged_state_authoritative": True, 

235 }, 

236 "closeout": { 

237 "comment_targets": ["issue", "pull_request"], 

238 "capture_marker_required": True, 

239 "status_done_after_merge_only": True, 

240 }, 

241 } 

242 

243 

244@dataclass(frozen=True) 

245class MergeDecision: 

246 action: str # "merge" | "defer" | "block" 

247 reason: str 

248 

249 

250def decide_merge( 

251 verdict: Verdict, 

252 *, 

253 window_open: bool, 

254 is_blocker: bool = False, 

255 unrun_blocking_gates: tuple[str, ...] = (), 

256) -> MergeDecision: 

257 """Decide what to do with a green-or-not PR given the window. 

258 

259 * blocking findings ⇒ **block** (never merges); 

260 * a required gate that nobody ran ⇒ **block** (no verdict exists to clear it); 

261 * outside the merge window and not a blocker ⇒ **defer** to the morning queue; 

262 * otherwise ⇒ **merge**. A blocker bypasses the window (but never the findings). 

263 

264 ``unrun_blocking_gates`` names ``on_fail: block`` gates this run did not execute — 

265 agentic gates reach the command-only runner, which does not dispatch them. The 

266 assessment must say so: :func:`keel.ledger.record_gates_passed` refuses to certify 

267 such a record, so reporting "clear to merge" would promise a merge that 

268 ``keel merge`` will then refuse, with the operator given no reason why. 

269 """ 

270 if verdict.blocked: 

271 return MergeDecision("block", "blocking findings present") 

272 if unrun_blocking_gates: 

273 listed = ", ".join(unrun_blocking_gates) 

274 return MergeDecision( 

275 "block", 

276 f"required gate(s) not run: {listed} — record a result with " 

277 "--gate-result <id>=pass|fail once the gate has been dispatched", 

278 ) 

279 if not window_open and not is_blocker: 

280 return MergeDecision("defer", "outside merge window (night no-merge)") 

281 reason = "blocker bypass" if (is_blocker and not window_open) else "clear to merge" 

282 return MergeDecision("merge", reason) 

283 

284 

285def should_run_fixloop(verdict: Verdict, *, current_round: int, cap: int = MAX_FIX_ROUNDS) -> bool: 

286 """True if there are blocking findings and the fix budget is not exhausted.""" 

287 return verdict.blocked and current_round < cap 

288 

289 

290def ci_passing(ci_conclusion: str | None) -> bool | None: 

291 """Interpret a check-rollup string (e.g. ``"SUCCESS,FAILURE"``). ``None`` == unknown.""" 

292 if ci_conclusion is None: 

293 return None 

294 parts = [p.strip().upper() for p in ci_conclusion.split(",") if p.strip()] 

295 if not parts: 

296 return None 

297 # ⚡ Bolt: ~3.4x faster validation using C-level frozenset.issuperset 

298 # instead of generator expression 

299 return CI_OK_STATES.issuperset(parts) 

300 

301 

302def ci_ran(ci_conclusion: str | None) -> bool | None: 

303 """Did any check report for this head? ``None`` == we could not find out. 

304 

305 Separate from :func:`ci_passing` on purpose. "Every check passed" and "no check 

306 ran" are not the same fact, and folding them together is the defect in #675: an 

307 empty rollup used to reach the merge decision as *unknown*, and unknown did not 

308 block, so a PR nothing had verified assessed identically to a green one — then 

309 that assessment was written into the run ledger as evidence. 

310 

311 ``""`` is ``gh`` reporting an empty rollup (a fact about the PR) and returns 

312 **False**. ``None`` is ``gh`` never answered, or no PR was supplied at all (a 

313 fact about the runner) and stays **None** — keel does not block on what it 

314 could not observe, it blocks on having observed nothing. 

315 """ 

316 if ci_conclusion is None: 

317 return None 

318 return bool(ci_conclusion.strip()) 

319 

320 

321def missing_ci_workflows( 

322 workflow_names: Sequence[str] | None, 

323 ci_workflows: dict[str, str] | None, 

324) -> tuple[str, ...]: 

325 """Declared workflows in ``ci_workflows`` that reported nothing for this head. 

326 

327 ``knobs.ci_workflows`` is the project stating which workflows gate a merge, so 

328 presence can be checked against a **declaration** instead of inferred from an 

329 empty set — the difference between "I saw no failures" and "I saw the things 

330 that were supposed to run". 

331 

332 ``workflow_names`` must be *workflow* names (:func:`keel.github.ci_workflow_names`), 

333 not job names. The distinction is not cosmetic: `ci_workflows` is keyed ``CI``, 

334 while the rollup reports ``test (py3.13 / ubuntu-latest)``, so comparing against 

335 job names would report every declared workflow missing on any repo using a matrix. 

336 Matching is exact and case-insensitive — a prefix rule would let an unrelated 

337 ``testing-utils`` satisfy a declared ``test``. 

338 

339 ``()`` when nothing is declared or the names could not be read — absence of a 

340 declaration is not evidence of a missing run. 

341 """ 

342 if not ci_workflows or workflow_names is None: 

343 return () 

344 reported = {name.strip().lower() for name in workflow_names if name.strip()} 

345 return tuple(sorted( 

346 declared for declared in ci_workflows 

347 if declared.strip().lower() not in reported 

348 )) 

349 

350 

351def is_hotfix(labels: list[str] | tuple[str, ...], *, hotfix_label: str = "hotfix") -> bool: 

352 """True if the issue/PR carries the hotfix label (case-insensitive).""" 

353 # ⚡ Bolt Optimization: Unroll any() generator and pre-compute lower() target 

354 target = hotfix_label.lower() 

355 for label in labels: 

356 if label.strip().lower() == target: 

357 return True 

358 return False 

359 

360 

361@dataclass(frozen=True) 

362class ShipAssessment: 

363 tier: int 

364 reviewers: int 

365 window_open: bool 

366 ci_ok: bool | None 

367 merge: MergeDecision 

368 halted: bool = False # pause mode + outside window ⇒ pipeline halted 

369 bypassed_window: bool = False # hotfix merged outside the window (audited) 

370 review_contract: dict[str, Any] | None = None 

371 #: Did any check report? False == the rollup was empty (nothing verified this 

372 #: head); None == keel could not find out. Distinct from ``ci_ok`` (#675). 

373 ci_ran: bool | None = None 

374 #: Declared ``knobs.ci_workflows`` that produced no check for this head. 

375 missing_workflows: tuple[str, ...] = () 

376 

377 

378def assess( 

379 *, 

380 changed_files: list[str] | None, 

381 gate_verdict: Verdict, 

382 tier3_globs: tuple[str, ...] = (), 

383 docs_globs: tuple[str, ...] = (), 

384 allowlist_globs: tuple[str, ...] = (), 

385 timezone: str | None = None, 

386 merge_window: str | None = None, 

387 merge_window_mode: str = "freeze", 

388 ci_conclusion: str | None = None, 

389 ci_check_names: Sequence[str] | None = None, 

390 ci_workflow_names: Sequence[str] | None = None, 

391 ci_workflows: dict[str, str] | None = None, 

392 now=None, 

393 is_blocker: bool = False, 

394 unrun_blocking_gates: tuple[str, ...] = (), 

395 reviewer_override: int | None = None, 

396 review_comments: str = "inline", 

397 gates: tuple[str, ...] = (), 

398 policy_pack: dict[str, Any] | None = None, 

399 jury: bool = False, 

400 no_jury: bool = False, 

401 jury_advisory: bool = False, 

402) -> ShipAssessment: 

403 """The whole deterministic ship decision in one place: tier → reviewers, window, 

404 CI, and the final merge action. Pure — identical inputs give identical output. 

405 

406 ``merge_window_mode`` 'pause' halts the pipeline outside the window; 'freeze' 

407 (default) only blocks the merge. ``is_blocker`` (a hotfix) bypasses the window — 

408 but never the findings or a failing CI. 

409 

410 ``changed_files`` is ``None`` when git could not be read (as 

411 :func:`keel.git.changed_files` reports it), which is deliberately *not* the same 

412 as ``[]``. An empty list classifies as the default tier; an unreadable one 

413 classifies fail-closed at :data:`keel.classify.UNKNOWN_TIER`, so a change nobody 

414 could see never buys itself a lighter review contract.""" 

415 tier = ( 

416 classify.UNKNOWN_TIER 

417 if changed_files is None 

418 else classify.tier_for_files(changed_files, tier3_globs=tier3_globs, 

419 docs_globs=docs_globs, 

420 allowlist_globs=allowlist_globs) 

421 ) 

422 reviewers = reviewer_override if reviewer_override is not None else reviewer_count(tier) 

423 window_open = ( 

424 is_merge_open(timezone, merge_window, now=now) if (timezone and merge_window) else True 

425 ) 

426 halted = (merge_window_mode == "pause") and not window_open and not is_blocker 

427 ci_ok = ci_passing(ci_conclusion) 

428 ran = ci_ran(ci_conclusion) 

429 docs_only = changed_files is not None and classify.is_docs_only( 

430 list(changed_files), docs_globs 

431 ) 

432 missing = () if docs_only else missing_ci_workflows(ci_workflow_names, ci_workflows) 

433 if ci_ok is False: 

434 merge = MergeDecision("block", "CI failing") 

435 elif ran is False and not docs_only: 

436 # Fail closed, and say which it was: an operator needs "nothing verified 

437 # this commit" to read differently from "a check went red". 

438 # 

439 # The docs-only carve-out is not a softening — it is what `keel merge` 

440 # already applies to its own `no-checks` state (cli._ci_state), and this 

441 # assessment must not contradict the gate it is predicting. A docs-only 

442 # change legitimately matches no workflow's path filter; anything else 

443 # with an empty rollup was simply never verified. 

444 merge = MergeDecision("block", "no CI ran — nothing verified this commit") 

445 elif missing: 

446 merge = MergeDecision( 

447 "block", f"declared CI workflow(s) never ran: {', '.join(missing)}" 

448 ) 

449 else: 

450 merge = decide_merge(gate_verdict, window_open=window_open, is_blocker=is_blocker, 

451 unrun_blocking_gates=unrun_blocking_gates) 

452 bypassed = is_blocker and not window_open and merge.action == "merge" 

453 review_contract = resolve_review_contract( 

454 tier=tier, 

455 reviewer_override=reviewer_override, 

456 review_comments=review_comments, 

457 gates=gates, 

458 policy_pack=policy_pack, 

459 jury=jury, 

460 no_jury=no_jury, 

461 jury_advisory=jury_advisory, 

462 ) 

463 return ShipAssessment( 

464 tier, reviewers, window_open, ci_ok, merge, halted, bypassed, review_contract, 

465 ran, missing, 

466 )