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

1"""Deterministic consumer-neutral closure-comment renderer. 

2 

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. 

7 

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""" 

13 

14from __future__ import annotations 

15 

16from typing import Any 

17 

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) 

30 

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) 

42 

43 

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 } 

77 

78 

79def render_closure_comment(record: dict[str, Any]) -> str: 

80 """Render one ``ship_run`` ledger record as the ship outcome markdown comment. 

81 

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" 

103 

104 

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()}", ""] 

109 

110 

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 

124 

125 

126def _is_reviewer(reviewer: Any) -> bool: 

127 return isinstance(reviewer, str) and bool(reviewer.strip()) 

128 

129 

130def _is_jury(reviewer: str) -> bool: 

131 return "jury" in reviewer.lower() 

132 

133 

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" 

137 

138 

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 

153 

154 

155def _docs_touched(changes: Any) -> str: 

156 """Return ``"yes"`` when any changed file is documentation, else ``"no"``. 

157 

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" 

169 

170 

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) 

178 

179 

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}" 

191 

192 

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() 

202 

203 

204def _run_context(run_context: Any) -> list[str]: 

205 """Render the deterministic preflight Run context block. 

206 

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 ] 

223 

224 

225def _unknown(value: Any) -> str: 

226 if isinstance(value, str) and value.strip(): 

227 return value.strip() 

228 return "unknown" 

229 

230 

231def _jury_mode(value: Any) -> str: 

232 if isinstance(value, str) and value.strip(): 

233 return value.strip() 

234 return "off" 

235 

236 

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})" 

245 

246 

247def _is_scope(scope: Any) -> bool: 

248 return isinstance(scope, str) and bool(scope.strip()) 

249 

250 

251def _value(value: Any) -> str: 

252 if isinstance(value, str) and value.strip(): 

253 return value.strip() 

254 return "none" 

255 

256 

257def _watermark(watermark: Any) -> list[str]: 

258 """Render the optional attribution and viral watermark signature. 

259 

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]