custos-code 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
custos_code/report.py ADDED
@@ -0,0 +1,317 @@
1
+ """Renderers: Rich terminal receipt, markdown for a PR comment, static HTML report card.
2
+
3
+ One `Reviewed` in, three surfaces out. The terminal receipt is the demo; the markdown is what the
4
+ GitHub Action posts; the HTML card is what the usability sessions put in front of a person.
5
+
6
+ Marks are the product's vocabulary and never change between surfaces:
7
+ ✓ confirmed ✗ contradicted ? unwitnessed ○ unrecorded ≈ qualified ⚠ out_of_scope
8
+
9
+ Owner: Oliver (terminal, HTML), Ananya (PR comment).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ from collections.abc import Sequence
16
+
17
+ from rich.console import Console
18
+ from rich.text import Text
19
+
20
+ from .models import Claim, Coverage, LedgerEvent, Session, Verdict, VerdictRecord
21
+
22
+ MARKER = "<!-- custos-code-bot: pr-receipt -->"
23
+ ORDER = [
24
+ Verdict.CONTRADICTED,
25
+ Verdict.OUT_OF_SCOPE,
26
+ Verdict.QUALIFIED,
27
+ Verdict.UNRECORDED,
28
+ Verdict.UNWITNESSED,
29
+ Verdict.CONFIRMED,
30
+ ]
31
+
32
+ MARK: dict[Verdict, tuple[str, str]] = {
33
+ Verdict.CONFIRMED: ("✓", "green"),
34
+ Verdict.CONTRADICTED: ("✗", "red"),
35
+ Verdict.UNWITNESSED: ("?", "yellow"),
36
+ Verdict.UNRECORDED: ("○", "bright_black"),
37
+ Verdict.QUALIFIED: ("≈", "cyan"),
38
+ Verdict.OUT_OF_SCOPE: ("⚠", "magenta"),
39
+ }
40
+ _HEX = {
41
+ Verdict.CONFIRMED: "#1E7B4E",
42
+ Verdict.CONTRADICTED: "#B42318",
43
+ Verdict.UNWITNESSED: "#B25E09",
44
+ Verdict.UNRECORDED: "#5B6675",
45
+ Verdict.QUALIFIED: "#2457C5",
46
+ Verdict.OUT_OF_SCOPE: "#8A2BB2",
47
+ }
48
+
49
+
50
+ def tally(verdicts: Sequence[VerdictRecord]) -> str:
51
+ counts: dict[Verdict, int] = {}
52
+ for r in verdicts:
53
+ counts[r.verdict] = counts.get(r.verdict, 0) + 1
54
+ return " · ".join(f"{n} {MARK[v][0]}" for v, n in counts.items()) or "no claims found"
55
+
56
+
57
+ def _evidence_for(rec: VerdictRecord, ledger: Sequence[LedgerEvent]) -> list[LedgerEvent]:
58
+ want = set(rec.evidence)
59
+ return [e for e in ledger if e.seq in want]
60
+
61
+
62
+ def terminal(
63
+ claims: Sequence[Claim],
64
+ verdicts: Sequence[VerdictRecord],
65
+ ledger: Sequence[LedgerEvent],
66
+ console: Console,
67
+ *,
68
+ show_evidence: bool = False,
69
+ ) -> None:
70
+ """The receipt as it appears under the agent's message. The demo surface."""
71
+ by = {c.id: c for c in claims}
72
+ for rec in verdicts:
73
+ claim = by.get(rec.claim_id)
74
+ if claim is None:
75
+ continue
76
+ mark, colour = MARK[rec.verdict]
77
+ line = Text()
78
+ line.append(f" {mark} ", style=f"bold {colour}")
79
+ line.append(f"{rec.verdict.value:<13}", style=colour)
80
+ line.append(claim.text.strip()[:96])
81
+ console.print(line)
82
+ cited = " ".join(f"#{s}" for s in rec.evidence) or "—"
83
+ meta = f" tier {rec.tier} · {rec.method} · {cited} · {rec.rationale}"
84
+ if rec.qualifier:
85
+ meta += f" · {rec.qualifier}"
86
+ console.print(Text(meta[:200], style="dim"))
87
+ if show_evidence:
88
+ for e in _evidence_for(rec, ledger):
89
+ body = (e.output or str((e.input or {}).get("command", "")))[:110].replace(
90
+ "\n", " ⏎ "
91
+ )
92
+ console.print(Text(f" #{e.seq} {e.tool or ''} {body}", style="dim cyan"))
93
+ console.print(Text(f" custos-code · {tally(verdicts)}", style="bold"))
94
+
95
+
96
+ def markdown(
97
+ claims: Sequence[Claim], verdicts: Sequence[VerdictRecord], *, source: str = ""
98
+ ) -> str:
99
+ """What the GitHub Action posts on a PR."""
100
+ by = {c.id: c for c in claims}
101
+ out = [f"**Receipt for this PR's description** · {len(verdicts)} claims · {tally(verdicts)}"]
102
+ if source:
103
+ out.append(f"<sub>source: {source}</sub>")
104
+ out.append("")
105
+ out.append("| | claim | evidence |")
106
+ out.append("|---|---|---|")
107
+ for rec in verdicts:
108
+ claim = by.get(rec.claim_id)
109
+ if claim is None:
110
+ continue
111
+ cited = ", ".join(f"`#{s}`" for s in rec.evidence) or "—"
112
+ text = claim.text.strip().replace("|", "\\|")[:160]
113
+ out.append(
114
+ f"| {MARK[rec.verdict][0]} **{rec.verdict.value}** | {text} | {cited} "
115
+ f"<br><sub>{rec.rationale[:140]}</sub> |"
116
+ )
117
+ blocked = [r for r in verdicts if r.verdict == Verdict.CONTRADICTED]
118
+ if blocked:
119
+ out += [
120
+ "",
121
+ f"⚠️ **{len(blocked)} contradicted claim(s).** The description asserts work the "
122
+ "session log does not support. Label applied: `needs-receipt`.",
123
+ ]
124
+ return "\n".join(out)
125
+
126
+
127
+ def html_card(
128
+ claims: Sequence[Claim],
129
+ verdicts: Sequence[VerdictRecord],
130
+ ledger: Sequence[LedgerEvent],
131
+ *,
132
+ report: str = "",
133
+ title: str = "Receipt",
134
+ ) -> str:
135
+ """A self-contained page: the agent's report with marks, each opening to its evidence."""
136
+ by = {c.id: c for c in claims}
137
+ rows = []
138
+ for rec in verdicts:
139
+ claim = by.get(rec.claim_id)
140
+ if claim is None:
141
+ continue
142
+ mark, _ = MARK[rec.verdict]
143
+ colour = _HEX[rec.verdict]
144
+ ev = "".join(
145
+ f"<div class='ev'><span class='seq'>#{e.seq}</span> <span class='tool'>{html.escape(e.tool or '')}</span> "
146
+ f"{html.escape((e.output or str((e.input or {}).get('command', '')))[:300])}</div>"
147
+ for e in _evidence_for(rec, ledger)
148
+ )
149
+ rows.append(
150
+ f"<details><summary><span class='mark' style='color:{colour}'>{mark}</span>"
151
+ f"<span class='v' style='color:{colour}'>{rec.verdict.value}</span>"
152
+ f"<span class='claim'>{html.escape(claim.text.strip()[:200])}</span></summary>"
153
+ f"<div class='why'>tier {rec.tier} · {rec.method} · {html.escape(rec.rationale[:300])}</div>"
154
+ f"{ev or '<div class=\"ev dim\">no ledger evidence</div>'}</details>"
155
+ )
156
+ return f"""<!doctype html><meta charset="utf-8"><title>{html.escape(title)}</title>
157
+ <style>
158
+ :root{{color-scheme:light dark}}
159
+ body{{font:15px/1.55 ui-sans-serif,system-ui,-apple-system,sans-serif;max-width:860px;margin:2rem auto;
160
+ padding:0 1rem;background:Canvas;color:CanvasText}}
161
+ h1{{font-size:1.3rem;margin:0 0 .2rem}} .sub{{color:#6b7280;font-size:13px;margin-bottom:1.2rem}}
162
+ .report{{border-left:3px solid #d1d5db;padding:.6rem 1rem;margin:1rem 0;white-space:pre-wrap;
163
+ font-size:14px;color:#4b5563}}
164
+ details{{border:1px solid #d9dee6;border-radius:6px;margin:.45rem 0;padding:.5rem .7rem}}
165
+ summary{{cursor:pointer;display:flex;gap:.6rem;align-items:baseline;list-style:none}}
166
+ summary::-webkit-details-marker{{display:none}}
167
+ .mark{{font-weight:700;font-family:ui-monospace,monospace}}
168
+ .v{{font-size:12px;text-transform:uppercase;letter-spacing:.06em;min-width:6.5rem}}
169
+ .claim{{flex:1}}
170
+ .why{{color:#6b7280;font-size:13px;margin:.5rem 0 .4rem}}
171
+ .ev{{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;background:#f3f4f6;
172
+ border-radius:4px;padding:.35rem .5rem;margin:.25rem 0;white-space:pre-wrap;color:#111827}}
173
+ .ev.dim{{color:#9ca3af;background:none}} .seq{{color:#6b7280}} .tool{{color:#b45309}}
174
+ @media(prefers-color-scheme:dark){{.ev{{background:#1f2937;color:#e5e7eb}} .report{{color:#9ca3af}}}}
175
+ </style>
176
+ <h1>{html.escape(title)}</h1>
177
+ <div class="sub">{len(verdicts)} claims · {tally(verdicts)} · evidence from {len(ledger)} logged events</div>
178
+ {f'<div class="report">{html.escape(report[:2000])}</div>' if report else ''}
179
+ {''.join(rows)}
180
+ """
181
+
182
+
183
+ def _cell(text: str, limit: int = 160) -> str:
184
+ """Markdown table cells cannot hold newlines or bare pipes.
185
+
186
+ The text is verbatim agent output rendered into GitHub-flavoured Markdown, which passes HTML
187
+ through: `</table>` or an `<img>` in a claim would deform or hide the very rows meant to hold
188
+ that agent honest, so angle brackets are escaped too.
189
+ """
190
+ flat = " ".join(text.split())
191
+ if len(flat) > limit:
192
+ flat = flat[: limit - 1].rstrip() + "…"
193
+ return flat.replace("|", "\\|").replace("<", "&lt;").replace(">", "&gt;")
194
+
195
+
196
+ def _cited(record: VerdictRecord, ledger: Sequence[LedgerEvent]) -> str:
197
+ """Name the evidence, not just its line number: a reviewer should not have to open the log."""
198
+ by_seq = {e.seq: e for e in ledger}
199
+ parts: list[str] = []
200
+ for seq in record.evidence[:3]:
201
+ event = by_seq.get(seq)
202
+ if event is None:
203
+ parts.append(f"log {seq}")
204
+ continue
205
+ detail = ""
206
+ if event.input:
207
+ detail = str(
208
+ event.input.get("command")
209
+ or event.input.get("file_path")
210
+ or event.input.get("path")
211
+ or event.input.get("sha")
212
+ or ""
213
+ )
214
+ if not detail and event.paths:
215
+ detail = event.paths[0]
216
+ label = f"log {seq}"
217
+ if event.tool:
218
+ label += f" `{event.tool}`"
219
+ if detail:
220
+ label += f" {_cell(detail, 48)}"
221
+ if event.exit_code is not None:
222
+ label += f" → exit {event.exit_code}"
223
+ parts.append(label)
224
+ return ", ".join(parts) if parts else "—"
225
+
226
+
227
+ def _coverage_line(coverage: Sequence[Coverage]) -> str:
228
+ if not coverage:
229
+ return ""
230
+ done = sum(1 for c in coverage if c.status == "done")
231
+ requested = sum(1 for c in coverage if c.status != "unrequested")
232
+ unrequested = [c.requirement for c in coverage if c.status == "unrequested"]
233
+ needs_human = sum(1 for c in coverage if c.status == "needs_human")
234
+ bits = [f"**Intent coverage:** {done} of {requested} requested items claimed"]
235
+ if unrequested:
236
+ bits.append(
237
+ f"{len(unrequested)} unrequested change{'s' if len(unrequested) > 1 else ''} "
238
+ f"({', '.join(_cell(u, 40) for u in unrequested[:3])})"
239
+ )
240
+ if needs_human:
241
+ bits.append(f"{needs_human} needs human")
242
+ return " · ".join(bits)
243
+
244
+
245
+ def pr_comment(
246
+ session: Session,
247
+ claims: Sequence[Claim],
248
+ verdicts: Sequence[VerdictRecord],
249
+ ledger: Sequence[LedgerEvent],
250
+ *,
251
+ pr_url: str | None = None,
252
+ coverage: Sequence[Coverage] | None = None,
253
+ receipt_url: str | None = None,
254
+ ) -> str:
255
+ """The Action's comment (sketch B). Contradictions first: a reviewer reads the worst news first.
256
+
257
+ Carries the marker so the Action edits one comment instead of stacking, and the integrity line
258
+ so a reader can tell a complete record from a partial one (invariant 7).
259
+ """
260
+ by_id = {c.id: c for c in claims}
261
+ counts = {v: sum(1 for r in verdicts if r.verdict is v) for v in Verdict}
262
+ headline = (
263
+ f"**Receipt for this PR's description** · {len(verdicts)} "
264
+ f"claim{'s' if len(verdicts) != 1 else ''} · source: {session.agent} "
265
+ f"session `{session.id[:12]}`"
266
+ )
267
+ integrity = (
268
+ f"ledger `{session.ledger_root_hash[:8]}` · {session.n_events} events · "
269
+ f"integrity {session.integrity_score:.2f}"
270
+ )
271
+
272
+ lines = [MARKER, headline, "", integrity, ""]
273
+ if not verdicts:
274
+ lines.append("_No claims found in the description: nothing to check._")
275
+ return "\n".join(lines) + "\n"
276
+
277
+ lines += ["| | Claim | Evidence | Tier |", "|---|---|---|---|"]
278
+ for verdict in ORDER:
279
+ for record in [r for r in verdicts if r.verdict is verdict]:
280
+ claim = by_id.get(record.claim_id)
281
+ rationale = _cell(record.rationale, 80)
282
+ if record.qualifier:
283
+ qualifier = _cell(record.qualifier, 60)
284
+ rationale = f"{rationale} ({qualifier})" if rationale else qualifier
285
+ evidence = _cited(record, ledger)
286
+ detail = f"{evidence}<br>{rationale}" if rationale else evidence
287
+ lines.append(
288
+ f"| {MARK[verdict][0]} **{verdict.value}** | "
289
+ f"{_cell(claim.text if claim else record.claim_id)} | {detail} | "
290
+ f"{record.tier} · {record.method} |"
291
+ )
292
+
293
+ lines += ["", " · ".join(f"{counts[v]} {MARK[v][0]}" for v in ORDER if counts[v])]
294
+ line = _coverage_line(coverage or [])
295
+ if line:
296
+ lines += ["", line]
297
+ if counts[Verdict.CONTRADICTED]:
298
+ lines += [
299
+ "",
300
+ "> A contradicted claim means the log carries positive evidence against it "
301
+ "(a failing exit code, a command that never ran, a file that is not there). "
302
+ "Unwitnessed is not an accusation: the record simply does not say.",
303
+ ]
304
+ if counts[Verdict.OUT_OF_SCOPE]:
305
+ lines += [
306
+ "",
307
+ "> `out_of_scope` means an action reached outside what this session was asked to "
308
+ "touch (SCOPE.md §4) -- a boundary violation, not evidence a claim is false.",
309
+ ]
310
+ footer = []
311
+ if receipt_url:
312
+ footer.append(f"[Open full receipt]({receipt_url})")
313
+ if pr_url:
314
+ footer.append(f"[PR]({pr_url})")
315
+ footer.append("generated by `custos-code pr-comment`")
316
+ lines += ["", f"<sub>{' · '.join(footer)}</sub>"]
317
+ return "\n".join(lines) + "\n"