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
« 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.
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"""
8from __future__ import annotations
10from collections.abc import Sequence
11from dataclasses import dataclass
12from typing import Any
14from . import classify
15from .findings import Verdict
16from .window import is_merge_open
18#: Hard cap on review→fix rounds (matches ship's budget).
19MAX_FIX_ROUNDS = 3
21#: GitHub check-rollup conclusions that count as "not failing".
22CI_OK_STATES = frozenset({"SUCCESS", "NEUTRAL", "SKIPPED"})
24POSTING_MODES = frozenset({"inline", "summary"})
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
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)
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)
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 )
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.
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.
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 }
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 }
244@dataclass(frozen=True)
245class MergeDecision:
246 action: str # "merge" | "defer" | "block"
247 reason: str
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.
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).
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)
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
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)
302def ci_ran(ci_conclusion: str | None) -> bool | None:
303 """Did any check report for this head? ``None`` == we could not find out.
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.
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())
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.
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".
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``.
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 ))
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
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, ...] = ()
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.
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.
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 )