Coverage for src/keel/artifacts.py: 100%
85 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"""Canonical Markdown renderers for ship artifacts.
3These helpers keep public GitHub artifacts deterministic and consumer-neutral.
4Adapters should post the rendered bodies verbatim instead of hand-writing PR
5descriptions, review verdicts, jury verdicts, or extension result summaries.
6"""
8from __future__ import annotations
10from typing import Any
12from . import evidence
14SCHEMA_VERSION = "keel.artifacts.v1"
15EXTENSION_RESULT_MARKER = "<!-- keel.extension-result.v1 -->"
16ISSUE_UPDATE_MARKER = "<!-- keel.issue-update.v1 -->"
17STEP_HANDOFF_MARKER = "<!-- keel.step-handoff.v1 -->"
18RUN_CONTROL_HALT_MARKER = "<!-- keel.run-control-halt.v1 -->"
21def contract_as_dict() -> dict[str, Any]:
22 """Return the canonical artifact renderer contract for ship-like flows."""
23 return {
24 "schema_version": SCHEMA_VERSION,
25 "consumer_neutral": True,
26 "deterministic": True,
27 "renderers": {
28 "pr_body": "keel.artifacts.render_pr_body",
29 "issue_update": "keel.artifacts.render_issue_update",
30 "review_verdict": "keel.artifacts.render_review_verdict",
31 "jury_verdict": "keel.artifacts.render_jury_verdict",
32 "extension_result": "keel.artifacts.render_extension_result",
33 "step_handoff": "keel.artifacts.render_step_handoff",
34 "run_control_halt": "keel.artifacts.render_run_control_halt",
35 },
36 "markers": {
37 "review_verdict": evidence.REVIEW_VERDICT_MARKER,
38 "jury_verdict": evidence.JURY_VERDICT_MARKER,
39 "issue_update": ISSUE_UPDATE_MARKER,
40 "extension_result": EXTENSION_RESULT_MARKER,
41 "step_handoff": STEP_HANDOFF_MARKER,
42 "run_control_halt": RUN_CONTROL_HALT_MARKER,
43 },
44 "adapter_rule": "post rendered markdown verbatim when available",
45 }
48def render_pr_body(
49 *,
50 issue_number: int | None = None,
51 issue_intake: dict[str, Any] | None = None,
52 changed_files: list[str] | tuple[str, ...] = (),
53 testing: list[str] | tuple[str, ...] = (),
54 docs_impact: str | None = None,
55) -> str:
56 """Render the canonical PR body used by ship implementers."""
57 intake = issue_intake if isinstance(issue_intake, dict) else {}
58 lines = [
59 "## Summary",
60 f"- { _value(intake.get('deliverable'), 'Implement the requested change.') }",
61 "",
62 "## Context / Root Cause",
63 _value(intake.get("objective"), "See the linked issue for context."),
64 "",
65 "## Changes Made",
66 ]
67 files = [file for file in changed_files if isinstance(file, str)]
68 if files:
69 lines.extend(f"- Updated `{file}`." for file in files)
70 else:
71 lines.append("- No changed files recorded yet.")
72 lines.extend(["", "## Testing"])
73 tests = [item for item in testing if isinstance(item, str) and item.strip()]
74 lines.extend(f"- {item.strip()}" for item in tests) if tests else lines.append(
75 "- Not run yet; update this section before marking the PR ready."
76 )
77 lines.extend([
78 "",
79 "## Docs Impact",
80 _value(docs_impact, "Docs Impact: none — no operator-facing behavior changed."),
81 "",
82 _closing_reference(issue_number),
83 ])
84 return "\n".join(lines).rstrip() + "\n"
87def render_issue_update(
88 *,
89 issue_number: int | None = None,
90 pull_request: int | None = None,
91 status: str = "in-progress",
92 summary: str | None = None,
93 next_step: str | None = None,
94) -> str:
95 """Render a stable issue progress/update comment."""
96 lines = [
97 ISSUE_UPDATE_MARKER,
98 "",
99 "## Ship update",
100 "",
101 f"- **Issue:** {_issue(issue_number)}",
102 f"- **Pull request:** {_pr(pull_request)}",
103 f"- **Status:** {_value(status, 'in-progress')}",
104 f"- **Summary:** {_value(summary, 'No summary recorded.')}",
105 f"- **Next step:** {_value(next_step, 'Continue the ship workflow.')}",
106 ]
107 return "\n".join(lines) + "\n"
110def render_review_verdict(
111 *,
112 reviewer: str,
113 head_sha: str | None,
114 verdict: str = "LGTM",
115 scope: str | None = None,
116 findings: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
117 testing: str | None = None,
118 vendor: str | None = None,
119 model: str | None = None,
120) -> str:
121 """Render a head-bound reviewer verdict comment accepted by evidence verification.
123 When ``vendor`` (and optionally ``model``) is supplied, structured
124 ``vendor:`` / ``model:`` provenance lines are emitted so evidence
125 verification can enforce vendor distinctness across required verdicts. The
126 fields use the same vendor/model conventions as ``keel.provenance`` and are
127 omitted entirely when not supplied, so the default rendering is unchanged.
128 """
129 lines = [
130 evidence.REVIEW_VERDICT_MARKER,
131 f"reviewer: {_slug(reviewer)}",
132 f"head: {_value(head_sha, '<head-sha>')}",
133 ]
134 if isinstance(vendor, str) and vendor.strip():
135 lines.append(f"vendor: {_slug(vendor)}")
136 if isinstance(model, str) and model.strip():
137 lines.append(f"model: {_slug(model)}")
138 lines.extend([
139 "",
140 f"Verdict: {_value(verdict, 'LGTM')}",
141 "",
142 f"Scope reviewed: {_value(scope, 'Full changed-file diff and relevant contracts.')}",
143 "",
144 "Findings:",
145 ])
146 lines.extend(_finding_lines(findings))
147 lines.extend(["", f"Testing noted: {_value(testing, 'See PR Testing section.')}"])
148 return "\n".join(lines) + "\n"
151def render_jury_verdict(
152 *,
153 head_sha: str | None,
154 participants: list[str] | tuple[str, ...] = (),
155 verdict: str = "LGTM",
156 findings_summary: list[str] | tuple[str, ...] = (),
157 remaining_risks: str | None = None,
158) -> str:
159 """Render a head-bound jury verdict comment accepted by evidence verification."""
160 people = [person.strip() for person in participants if isinstance(person, str)
161 and person.strip()]
162 lines = [
163 evidence.JURY_VERDICT_MARKER,
164 f"head: {_value(head_sha, '<head-sha>')}",
165 "",
166 f"AI Jury verdict: {_value(verdict, 'LGTM')}.",
167 "",
168 f"Participants: {', '.join(people) if people else 'not recorded'}.",
169 "",
170 "Findings summary:",
171 ]
172 summaries = [item.strip() for item in findings_summary if isinstance(item, str)
173 and item.strip()]
174 lines.extend(f"- {item}" for item in summaries) if summaries else lines.append("- none")
175 lines.extend(["", f"Remaining risks: {_value(remaining_risks, 'none identified')}."])
176 return "\n".join(lines) + "\n"
179def render_extension_result(
180 *,
181 slot: str,
182 extension_id: str,
183 status: str,
184 mode: str,
185 summary: str | None = None,
186 artifacts: list[str] | tuple[str, ...] = (),
187 follow_ups: list[str] | tuple[str, ...] = (),
188) -> str:
189 """Render a canonical extension result block/comment."""
190 lines = [
191 EXTENSION_RESULT_MARKER,
192 "",
193 "## Extension result",
194 "",
195 f"- **Slot:** `{_value(slot, 'unknown')}`",
196 f"- **Extension:** `{_value(extension_id, 'unknown')}`",
197 f"- **Status:** {_value(status, 'not-recorded')}",
198 f"- **Mode:** {_value(mode, 'advisory')}",
199 f"- **Summary:** {_value(summary, 'No summary recorded.')}",
200 "- **Artifacts:**",
201 ]
202 artifact_lines = _string_bullets(artifacts)
203 lines.extend(artifact_lines if artifact_lines else [" - none"])
204 lines.append("- **Follow-ups:**")
205 follow_up_lines = _string_bullets(follow_ups)
206 lines.extend(follow_up_lines if follow_up_lines else [" - none"])
207 return "\n".join(lines) + "\n"
210def render_step_handoff(
211 *,
212 step_id: str,
213 step_name: str | None = None,
214 status: str = "complete",
215 summary: str | None = None,
216 next_step: str | None = None,
217 evidence_ids: list[str] | tuple[str, ...] = (),
218) -> str:
219 """Render the canonical structured handoff between backbone steps."""
220 lines = [
221 STEP_HANDOFF_MARKER,
222 "",
223 "## Step handoff",
224 "",
225 f"- **Step:** `{_value(step_id, 'unknown')}`",
226 f"- **Name:** {_value(step_name, 'not recorded')}",
227 f"- **Status:** {_value(status, 'complete')}",
228 f"- **Summary:** {_value(summary, 'No summary recorded.')}",
229 f"- **Next step:** {_value(next_step, 'Continue the backbone plan.')}",
230 "- **Evidence:**",
231 ]
232 evidence_lines = _string_bullets(evidence_ids)
233 lines.extend(evidence_lines if evidence_lines else [" - none"])
234 return "\n".join(lines) + "\n"
237def render_run_control_halt(
238 *,
239 control: str,
240 reason: str,
241 scope: str | None = None,
242 observed: int | str | None = None,
243 limit: int | str | None = None,
244 action: str | None = None,
245) -> str:
246 """Render a stable hard-halt reason emitted by run controls."""
247 lines = [
248 RUN_CONTROL_HALT_MARKER,
249 "",
250 "## Run control halt",
251 "",
252 f"- **Control:** `{_value(control, 'unknown')}`",
253 f"- **Reason:** {_value(reason, 'No reason recorded.')}",
254 f"- **Scope:** {_value(scope, 'run')}",
255 f"- **Observed:** {_value(observed, 'not recorded')}",
256 f"- **Limit:** {_value(limit, 'not recorded')}",
257 f"- **Action:** {_value(action, 'halt')}",
258 ]
259 return "\n".join(lines) + "\n"
262def _finding_lines(findings: list[dict[str, Any]] | tuple[dict[str, Any], ...]) -> list[str]:
263 if not findings:
264 return ["- none"]
265 lines: list[str] = []
266 for finding in findings:
267 severity = _value(finding.get("severity") if isinstance(finding, dict) else None, "nit")
268 message = _value(finding.get("message") if isinstance(finding, dict) else None, "")
269 if message:
270 lines.append(f"- {severity}: {message}")
271 return lines or ["- none"]
274def _string_bullets(values: list[str] | tuple[str, ...]) -> list[str]:
275 return [f" - {value.strip()}" for value in values if isinstance(value, str)
276 and value.strip()]
279def _closing_reference(issue_number: int | None) -> str:
280 return f"Closes #{issue_number}" if isinstance(issue_number, int) else "Refs #<issue-number>"
283def _issue(issue_number: int | None) -> str:
284 return f"#{issue_number}" if isinstance(issue_number, int) else "not recorded"
287def _pr(pull_request: int | None) -> str:
288 return f"#{pull_request}" if isinstance(pull_request, int) else "not opened"
291def slug(value: str) -> str:
292 """Stable, deterministic slug for reviewer/run-id sub-keys (public alias)."""
293 clean = "".join(ch.lower() if ch.isalnum() else "-" for ch in value.strip())
294 return "-".join(part for part in clean.split("-") if part) or "reviewer"
297def _slug(value: str) -> str:
298 return slug(value)
301def _value(value: Any, fallback: str) -> str:
302 if isinstance(value, str) and value.strip():
303 return value.strip()
304 return fallback