Coverage for src/keel/closure.py: 100%
113 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +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"
23# Project-neutral documentation detection. A changed file counts as docs when any
24# path component equals ``docs`` (case-insensitive) or its suffix is a documentation
25# format. Custom docs paths are a project-config/policy concern, not core: keep this
26# set generic so the consumer-neutrality guard holds.
27#
28# ``.txt`` is intentionally excluded: it is false-positive prone (e.g.
29# ``requirements.txt``, lockfile-style manifests) and matches plenty of non-docs.
30# A doc-ish text file (e.g. ``docs/notes.txt``) still counts via the ``docs/``
31# path-component rule, so rely on that directory rule for text docs.
32_DOC_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".rst", ".adoc"})
33_DOC_SUFFIXES_TUPLE = tuple(_DOC_SUFFIXES)
36def contract_as_dict() -> dict[str, Any]:
37 """Return the stable closure-comment contract consumed by ship adapters."""
38 return {
39 "schema_version": CLOSURE_SCHEMA_VERSION,
40 "comment_marker": COMMENT_MARKER,
41 "heading": HEADING,
42 "source": "run-ledger ship_run record",
43 "deterministic": True,
44 "consumer_neutral": True,
45 "mirror_not_parser": True,
46 "renderer": "keel.closure.render_closure_comment",
47 "sections": [
48 "implementer",
49 "reviewers",
50 "tester",
51 "pull_request",
52 "changed_files",
53 "docs_touched",
54 "capture",
55 "run_id",
56 "run_context",
57 ],
58 "run_context_fields": [
59 "host_agent",
60 "transport",
61 "profile",
62 "jury_mode",
63 "consent",
64 ],
65 "jury_label": JURY_LABEL,
66 }
69def render_closure_comment(record: dict[str, Any]) -> str:
70 """Render one ``ship_run`` ledger record as the ship outcome markdown comment.
72 Missing or ``None`` optional fields degrade gracefully. Only the target line is
73 omitted when its value is absent or blank; every other field always renders. The
74 implementer, reviewers, tester, run id, and capture all render ``none`` (capture
75 renders ``not recorded``) when missing. An empty or jury-only reviewer list
76 renders ``none`` / ``AI Jury``; a missing PR number renders ``none``; a
77 ``capture`` status of ``None`` renders ``not recorded``.
78 """
79 actors = record.get("actors") or {}
80 lines: list[str] = [COMMENT_MARKER, "", f"## {HEADING}", ""]
81 lines.extend(_target_line(record.get("target")))
82 lines.append(f"- **Implementer:** {_value(actors.get('implementer'))}")
83 lines.append(f"- **Reviewers:** {_reviewers(actors.get('reviewers'))}")
84 lines.append(f"- **Tester:** {_value(actors.get('tester'))}")
85 lines.append(f"- **PR:** {_pull_request(record.get('pull_request'))}")
86 lines.extend(_changed_files(record.get("changes")))
87 lines.append(f"- **Docs touched:** {_docs_touched(record.get('changes'))}")
88 lines.append(f"- **Capture:** {_capture(record.get('capture'))}")
89 lines.append(f"- **Run id:** {_value(record.get('run_id'))}")
90 lines.extend(_run_context(record.get("run_context")))
91 return "\n".join(lines) + "\n"
94def _target_line(target: Any) -> list[str]:
95 if not isinstance(target, str) or not target.strip():
96 return []
97 return [f"**Target:** {target.strip()}", ""]
100def _reviewers(reviewers: Any) -> str:
101 if not isinstance(reviewers, list):
102 return "none"
103 entries = [reviewer.strip() for reviewer in reviewers if _is_reviewer(reviewer)]
104 listed = [reviewer for reviewer in entries if not _is_jury(reviewer)]
105 has_jury = any(_is_jury(reviewer) for reviewer in entries)
106 if not listed:
107 return JURY_LABEL if has_jury else "none"
108 rendered = ", ".join(listed)
109 if has_jury:
110 return f"{rendered} — {JURY_LABEL}"
111 return rendered
114def _is_reviewer(reviewer: Any) -> bool:
115 return isinstance(reviewer, str) and bool(reviewer.strip())
118def _is_jury(reviewer: str) -> bool:
119 return "jury" in reviewer.lower()
122def _pull_request(pull_request: Any) -> str:
123 number = pull_request.get("number") if isinstance(pull_request, dict) else None
124 return f"#{number}" if isinstance(number, int) else "none"
127def _changed_files(changes: Any) -> list[str]:
128 block = changes if isinstance(changes, dict) else {}
129 files = block.get("files")
130 files = list(files) if isinstance(files, list) else []
131 count = block.get("file_count")
132 count = count if isinstance(count, int) else len(files)
133 lines = [f"- **Changed files:** {count}"]
134 lines.extend(f" - `{file}`" for file in files)
135 return lines
138def _docs_touched(changes: Any) -> str:
139 """Return ``"yes"`` when any changed file is documentation, else ``"no"``.
141 Derived deterministically and consumer-neutrally from ``changes.files``; the
142 ledger schema is unchanged and no project config is read.
143 """
144 block = changes if isinstance(changes, dict) else {}
145 files = block.get("files")
146 files = files if isinstance(files, list) else []
147 return "yes" if any(_is_doc(file) for file in files) else "no"
150def _is_doc(file: Any) -> bool:
151 if not isinstance(file, str):
152 return False
153 lowered = file.lower()
154 if any(part == "docs" for part in lowered.replace("\\", "/").split("/")):
155 return True
156 return lowered.endswith(_DOC_SUFFIXES_TUPLE)
159def _capture(capture: Any) -> str:
160 block = capture if isinstance(capture, dict) else {}
161 status = block.get("status")
162 if not isinstance(status, str) or not status:
163 return "not recorded"
164 reason = block.get("reason")
165 learning = _learning(block.get("learning"))
166 suffix = f"; learning: {learning}" if learning else ""
167 if isinstance(reason, str) and reason.strip():
168 return f"{status} ({reason.strip()}){suffix}"
169 return f"{status}{suffix}"
172def _learning(learning: Any) -> str | None:
173 block = learning if isinstance(learning, dict) else {}
174 decision = block.get("decision")
175 if not isinstance(decision, str) or not decision.strip():
176 return None
177 reason = block.get("reason")
178 if isinstance(reason, str) and reason.strip():
179 return f"{decision.strip()} ({reason.strip()})"
180 return decision.strip()
183def _run_context(run_context: Any) -> list[str]:
184 """Render the deterministic preflight Run context block.
186 Always emitted (additive section, appended after the existing lines). Each
187 field degrades gracefully when missing: host agent / profile / consent
188 status render ``unknown``; transport renders ``unknown``; jury renders
189 ``off``; an empty consent scope list renders ``none``.
190 """
191 block = run_context if isinstance(run_context, dict) else {}
192 return [
193 "",
194 "### Run context",
195 "",
196 f"- **Host agent:** {_unknown(block.get('host_agent'))}",
197 f"- **Transport:** {_unknown(block.get('transport'))}",
198 f"- **Profile:** {_unknown(block.get('profile'))}",
199 f"- **Jury:** {_jury_mode(block.get('jury_mode'))}",
200 f"- **Consent:** {_consent(block.get('consent'))}",
201 ]
204def _unknown(value: Any) -> str:
205 if isinstance(value, str) and value.strip():
206 return value.strip()
207 return "unknown"
210def _jury_mode(value: Any) -> str:
211 if isinstance(value, str) and value.strip():
212 return value.strip()
213 return "off"
216def _consent(consent: Any) -> str:
217 block = consent if isinstance(consent, dict) else {}
218 status = _unknown(block.get("status"))
219 scopes = block.get("scopes")
220 scopes = scopes if isinstance(scopes, list) else []
221 listed = [scope.strip() for scope in scopes if _is_scope(scope)]
222 rendered = ", ".join(listed) if listed else "none"
223 return f"{status} (scopes: {rendered})"
226def _is_scope(scope: Any) -> bool:
227 return isinstance(scope, str) and bool(scope.strip())
230def _value(value: Any) -> str:
231 if isinstance(value, str) and value.strip():
232 return value.strip()
233 return "none"