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

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" 

22 

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) 

34 

35 

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 } 

67 

68 

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

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

71 

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" 

92 

93 

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

98 

99 

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 

112 

113 

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

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

116 

117 

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

119 return "jury" in reviewer.lower() 

120 

121 

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" 

125 

126 

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 

136 

137 

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

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

140 

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" 

148 

149 

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) 

157 

158 

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

170 

171 

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

181 

182 

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

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

185 

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 ] 

202 

203 

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

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

206 return value.strip() 

207 return "unknown" 

208 

209 

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

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

212 return value.strip() 

213 return "off" 

214 

215 

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

224 

225 

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

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

228 

229 

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

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

232 return value.strip() 

233 return "none"