Coverage for src/keel/closure.py: 100%
126 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"""Deterministic consumer-neutral closure-comment renderer.
3The ``ship`` backbone posts a human-readable "ship outcome" comment to both the
4issue and the PR at s11. This module renders that markdown **from** a structured
5``ship_run`` ledger record (see :func:`keel.ledger.build_ship_run_record`); it is a
6mirror of the ledger, never a parser source.
8Pure-core / thin-I/O: :func:`render_closure_comment` takes a plain dict and returns
9markdown. It is deterministic (stable ordering, no wall-clock, no randomness) and
10consumer-neutral — the project codename comes from the record's ``target``, never a
11literal baked into core.
12"""
14from __future__ import annotations
16from typing import Any
18CLOSURE_SCHEMA_VERSION = "keel.closure-comment.v1"
19COMMENT_MARKER = f"<!-- {CLOSURE_SCHEMA_VERSION} -->"
20HEADING = "Ship outcome"
21JURY_LABEL = "AI Jury"
22WATERMARK_MARKER = "<!-- keel.watermark.v1 -->"
23WATERMARK_BODY = (
24 "⚓ **Shipped by [keel](https://github.com/berkayturanci/keel)** — "
25 "*Driven on fixed backbone `s0`→`s12` "
26 "(with [ai-jury](https://github.com/berkayturanci/ai-jury) consensus)* \n"
27 "[⭐ Star on GitHub](https://github.com/berkayturanci/keel) · "
28 "[Add Keel to your repo](https://github.com/berkayturanci/keel#readme)"
29)
31# Project-neutral documentation detection. A changed file counts as docs when any
32# path component equals ``docs`` (case-insensitive) or its suffix is a documentation
33# format. Custom docs paths are a project-config/policy concern, not core: keep this
34# set generic so the consumer-neutrality guard holds.
35#
36# ``.txt`` is intentionally excluded: it is false-positive prone (e.g.
37# ``requirements.txt``, lockfile-style manifests) and matches plenty of non-docs.
38# A doc-ish text file (e.g. ``docs/notes.txt``) still counts via the ``docs/``
39# path-component rule, so rely on that directory rule for text docs.
40_DOC_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".rst", ".adoc"})
41_DOC_SUFFIXES_TUPLE = tuple(_DOC_SUFFIXES)
44def contract_as_dict() -> dict[str, Any]:
45 """Return the stable closure-comment contract consumed by ship adapters."""
46 return {
47 "schema_version": CLOSURE_SCHEMA_VERSION,
48 "comment_marker": COMMENT_MARKER,
49 "heading": HEADING,
50 "source": "run-ledger ship_run record",
51 "deterministic": True,
52 "consumer_neutral": True,
53 "mirror_not_parser": True,
54 "renderer": "keel.closure.render_closure_comment",
55 "sections": [
56 "implementer",
57 "reviewers",
58 "tester",
59 "pull_request",
60 "changed_files",
61 "docs_touched",
62 "capture",
63 "run_id",
64 "run_context",
65 "watermark",
66 ],
67 "run_context_fields": [
68 "host_agent",
69 "transport",
70 "profile",
71 "jury_mode",
72 "consent",
73 ],
74 "jury_label": JURY_LABEL,
75 "watermark_marker": WATERMARK_MARKER,
76 }
79def render_closure_comment(record: dict[str, Any]) -> str:
80 """Render one ``ship_run`` ledger record as the ship outcome markdown comment.
82 Missing or ``None`` optional fields degrade gracefully. Only the target line is
83 omitted when its value is absent or blank; every other field always renders. The
84 implementer, reviewers, tester, run id, and capture all render ``none`` (capture
85 renders ``not recorded``) when missing. An empty or jury-only reviewer list
86 renders ``none`` / ``AI Jury``; a missing PR number renders ``none``; a
87 ``capture`` status of ``None`` renders ``not recorded``.
88 """
89 actors = record.get("actors") or {}
90 lines: list[str] = [COMMENT_MARKER, "", f"## {HEADING}", ""]
91 lines.extend(_target_line(record.get("target")))
92 lines.append(f"- **Implementer:** {_value(actors.get('implementer'))}")
93 lines.append(f"- **Reviewers:** {_reviewers(actors.get('reviewers'))}")
94 lines.append(f"- **Tester:** {_value(actors.get('tester'))}")
95 lines.append(f"- **PR:** {_pull_request(record.get('pull_request'))}")
96 lines.extend(_changed_files(record.get("changes")))
97 lines.append(f"- **Docs touched:** {_docs_touched(record.get('changes'))}")
98 lines.append(f"- **Capture:** {_capture(record.get('capture'))}")
99 lines.append(f"- **Run id:** {_value(record.get('run_id'))}")
100 lines.extend(_run_context(record.get("run_context")))
101 lines.extend(_watermark(record.get("watermark")))
102 return "\n".join(lines) + "\n"
105def _target_line(target: Any) -> list[str]:
106 if not isinstance(target, str) or not target.strip():
107 return []
108 return [f"**Target:** {target.strip()}", ""]
111def _reviewers(reviewers: Any) -> str:
112 if not isinstance(reviewers, list):
113 return "none"
114 entries = [reviewer.strip() for reviewer in reviewers if _is_reviewer(reviewer)]
115 listed = [reviewer for reviewer in entries if not _is_jury(reviewer)]
116 # ⚡ Bolt Optimization: Compare lengths instead of redundant predicate evaluation via any()
117 has_jury = len(listed) < len(entries)
118 if not listed:
119 return JURY_LABEL if has_jury else "none"
120 rendered = ", ".join(listed)
121 if has_jury:
122 return f"{rendered} — {JURY_LABEL}"
123 return rendered
126def _is_reviewer(reviewer: Any) -> bool:
127 return isinstance(reviewer, str) and bool(reviewer.strip())
130def _is_jury(reviewer: str) -> bool:
131 return "jury" in reviewer.lower()
134def _pull_request(pull_request: Any) -> str:
135 number = pull_request.get("number") if isinstance(pull_request, dict) else None
136 return f"#{number}" if isinstance(number, int) else "none"
139def _changed_files(changes: Any) -> list[str]:
140 block = changes if isinstance(changes, dict) else {}
141 if block.get("unreadable") is True:
142 # The record says git could not be read. The defensive coercions below would
143 # otherwise absorb the `None`s and post "0" to the PR — an affirmative claim
144 # about a diff nobody saw, in the one artifact a human actually reads.
145 return ["- **Changed files:** unreadable (git diff failed)"]
146 files = block.get("files")
147 files = list(files) if isinstance(files, list) else []
148 count = block.get("file_count")
149 count = count if isinstance(count, int) else len(files)
150 lines = [f"- **Changed files:** {count}"]
151 lines.extend(f" - `{file}`" for file in files)
152 return lines
155def _docs_touched(changes: Any) -> str:
156 """Return ``"yes"`` when any changed file is documentation, else ``"no"``.
158 Derived deterministically and consumer-neutrally from ``changes.files``; the
159 ledger schema is unchanged and no project config is read. An unreadable diff
160 answers ``"unknown"`` rather than ``"no"`` — the file list it would be derived
161 from does not exist.
162 """
163 block = changes if isinstance(changes, dict) else {}
164 if block.get("unreadable") is True:
165 return "unknown"
166 files = block.get("files")
167 files = files if isinstance(files, list) else []
168 return "yes" if any(_is_doc(file) for file in files) else "no"
171def _is_doc(file: Any) -> bool:
172 if not isinstance(file, str):
173 return False
174 lowered = file.lower()
175 if "docs" in lowered.replace("\\", "/").split("/"):
176 return True
177 return lowered.endswith(_DOC_SUFFIXES_TUPLE)
180def _capture(capture: Any) -> str:
181 block = capture if isinstance(capture, dict) else {}
182 status = block.get("status")
183 if not isinstance(status, str) or not status:
184 return "not recorded"
185 reason = block.get("reason")
186 learning = _learning(block.get("learning"))
187 suffix = f"; learning: {learning}" if learning else ""
188 if isinstance(reason, str) and reason.strip():
189 return f"{status} ({reason.strip()}){suffix}"
190 return f"{status}{suffix}"
193def _learning(learning: Any) -> str | None:
194 block = learning if isinstance(learning, dict) else {}
195 decision = block.get("decision")
196 if not isinstance(decision, str) or not decision.strip():
197 return None
198 reason = block.get("reason")
199 if isinstance(reason, str) and reason.strip():
200 return f"{decision.strip()} ({reason.strip()})"
201 return decision.strip()
204def _run_context(run_context: Any) -> list[str]:
205 """Render the deterministic preflight Run context block.
207 Always emitted (additive section, appended after the existing lines). Each
208 field degrades gracefully when missing: host agent / profile / consent
209 status render ``unknown``; transport renders ``unknown``; jury renders
210 ``off``; an empty consent scope list renders ``none``.
211 """
212 block = run_context if isinstance(run_context, dict) else {}
213 return [
214 "",
215 "### Run context",
216 "",
217 f"- **Host agent:** {_unknown(block.get('host_agent'))}",
218 f"- **Transport:** {_unknown(block.get('transport'))}",
219 f"- **Profile:** {_unknown(block.get('profile'))}",
220 f"- **Jury:** {_jury_mode(block.get('jury_mode'))}",
221 f"- **Consent:** {_consent(block.get('consent'))}",
222 ]
225def _unknown(value: Any) -> str:
226 if isinstance(value, str) and value.strip():
227 return value.strip()
228 return "unknown"
231def _jury_mode(value: Any) -> str:
232 if isinstance(value, str) and value.strip():
233 return value.strip()
234 return "off"
237def _consent(consent: Any) -> str:
238 block = consent if isinstance(consent, dict) else {}
239 status = _unknown(block.get("status"))
240 scopes = block.get("scopes")
241 scopes = scopes if isinstance(scopes, list) else []
242 listed = [scope.strip() for scope in scopes if _is_scope(scope)]
243 rendered = ", ".join(listed) if listed else "none"
244 return f"{status} (scopes: {rendered})"
247def _is_scope(scope: Any) -> bool:
248 return isinstance(scope, str) and bool(scope.strip())
251def _value(value: Any) -> str:
252 if isinstance(value, str) and value.strip():
253 return value.strip()
254 return "none"
257def _watermark(watermark: Any) -> list[str]:
258 """Render the optional attribution and viral watermark signature.
260 Emitted by default (when ``watermark is not False``). Can be disabled via
261 ``watermark: false`` in record or customized with a string value.
262 """
263 if watermark is False:
264 return []
265 if isinstance(watermark, str) and watermark.strip():
266 return ["", "---", watermark.strip()]
267 return ["", "---", WATERMARK_BODY]