countersign-cli 0.2.0__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.
countersign/jsscan.py ADDED
@@ -0,0 +1,422 @@
1
+ # audited on 20260903
2
+ """Structural check for TypeScript and JavaScript: functions that do nothing.
3
+
4
+ Python gets this check from the ``ast`` module. There is no parser for
5
+ TypeScript in the standard library, and a dependency is not on the table, so
6
+ this module does the one narrow thing the check needs without parsing the
7
+ language: it blanks out every comment, string, template literal and regular
8
+ expression literal (keeping line breaks), then looks for the three shapes
9
+ an agent leaves behind when it declares a function and never writes it:
10
+
11
+ function name(...) {} declarations, async, generators, default
12
+ name(...) {} class and object literal methods
13
+ export const name = (...) => {} exported arrow and function expressions
14
+
15
+ Only a body that is empty before blanking counts: a block holding nothing
16
+ but a comment is a documented no-op, which is a decision, not unfinished
17
+ work (and whatever the comment admits is the marker rules' business). The
18
+ check is tuned to never fire on honest code:
19
+
20
+ - constructors are skipped (parameter properties make an empty body normal),
21
+ - Angular lifecycle hooks (``ngOnInit`` and friends) are skipped,
22
+ - control flow keywords are never treated as a method name,
23
+ - unexported arrow functions are skipped (callbacks and defaults are
24
+ legitimately empty), as is an exported ``noop``,
25
+ - declaration files (``.d.ts``) and minified sources are skipped outright.
26
+
27
+ Overloads, abstract methods and interface methods have no body and match
28
+ nothing. Anything this cannot see is a miss, never a false positive.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ # A line longer than this is generated or minified code, where empty
36
+ # functions are polyfill noise rather than an agent's unfinished work.
37
+ MINIFIED_LINE_LENGTH = 1000
38
+
39
+ # Words that can precede ``(`` and a block without being a method name.
40
+ NOT_A_METHOD = frozenset({
41
+ "if", "for", "while", "switch", "catch", "with", "function", "return",
42
+ "await", "yield", "typeof", "new", "throw", "void", "delete", "in", "of",
43
+ "case", "else", "do", "try", "finally", "constructor", "super", "import",
44
+ "export", "as", "from", "declare", "abstract", "class", "interface",
45
+ "namespace", "module", "enum", "type", "let", "const", "var", "instanceof",
46
+ })
47
+
48
+ # Method names whose empty body is a framework convention, not unfinished work.
49
+ FRAMEWORK_HOOK = re.compile(r"^ng[A-Z]\w*$")
50
+ NOOP_NAME = re.compile(r"^no[_-]?op$", re.IGNORECASE)
51
+
52
+ MODIFIERS = frozenset({"public", "private", "protected", "static", "async", "override", "readonly", "get", "set", "declare", "abstract"})
53
+
54
+ _IDENT = r"[A-Za-z_$][\w$]*"
55
+ _FUNCTION_DECL = re.compile(r"\bfunction\b\s*(\*)?\s*(" + _IDENT + r")?\s*(?=[<(])")
56
+ _METHOD_LINE = re.compile(r"^[ \t]*((?:(?:" + "|".join(sorted(MODIFIERS)) + r")\s+)*)(" + _IDENT + r")\s*(?=[<(])", re.MULTILINE)
57
+ _EXPORTED_ARROW = re.compile(r"\bexport\s+(?:const|let|var)\s+(" + _IDENT + r")\b")
58
+ _IDENT_AT = re.compile(_IDENT)
59
+
60
+ # Characters after which a ``/`` begins a regular expression literal rather
61
+ # than a division. Good enough for blanking; a wrong guess only affects
62
+ # what is blanked on that line.
63
+ _REGEX_PRECEDERS = set("(,=:[!&|?{};+-*%<>~^")
64
+
65
+
66
+ def mask_source(source: str) -> str:
67
+ """Blank every comment, string, template literal and regex literal.
68
+
69
+ Every blanked character becomes a space; line breaks and everything
70
+ else are kept, so offsets and line numbers in the result are the
71
+ offsets and line numbers of the source.
72
+ """
73
+ out = list(source)
74
+ n = len(source)
75
+ i = 0
76
+ last_significant: str | None = None
77
+ # Stack of template-literal expression depths: inside ``${ ... }`` code
78
+ # resumes, and a ``}`` at depth zero returns to the template.
79
+ template_depths: list[int] = []
80
+
81
+ def blank(start: int, end: int) -> None:
82
+ for k in range(start, end):
83
+ if out[k] not in "\r\n":
84
+ out[k] = " "
85
+
86
+ while i < n:
87
+ c = source[i]
88
+ nxt = source[i + 1] if i + 1 < n else ""
89
+ if c == "/" and nxt == "/":
90
+ j = i
91
+ while j < n and source[j] not in "\r\n":
92
+ j += 1
93
+ blank(i, j)
94
+ i = j
95
+ continue
96
+ if c == "/" and nxt == "*":
97
+ j = source.find("*/", i + 2)
98
+ j = n if j < 0 else j + 2
99
+ blank(i, j)
100
+ i = j
101
+ continue
102
+ if c in "'\"":
103
+ j = i + 1
104
+ while j < n and source[j] != c and source[j] not in "\r\n":
105
+ if source[j] == "\\":
106
+ j += 1
107
+ j += 1
108
+ blank(i + 1, min(j, n))
109
+ i = min(j + 1, n)
110
+ last_significant = c
111
+ continue
112
+ if c == "`":
113
+ i = _mask_template(source, out, i, template_depths)
114
+ last_significant = "`"
115
+ continue
116
+ if template_depths:
117
+ if c == "{":
118
+ template_depths[-1] += 1
119
+ elif c == "}":
120
+ if template_depths[-1] == 0:
121
+ template_depths.pop()
122
+ i = _mask_template(source, out, i, template_depths, resume=True)
123
+ last_significant = "`"
124
+ continue
125
+ template_depths[-1] -= 1
126
+ if c == "/" and (last_significant is None or last_significant in _REGEX_PRECEDERS or last_significant == "return"):
127
+ j = i + 1
128
+ in_class = False
129
+ while j < n and source[j] not in "\r\n":
130
+ ch = source[j]
131
+ if ch == "\\":
132
+ j += 2
133
+ continue
134
+ if in_class:
135
+ if ch == "]":
136
+ in_class = False
137
+ elif ch == "[":
138
+ in_class = True
139
+ elif ch == "/":
140
+ break
141
+ j += 1
142
+ blank(i + 1, min(j, n))
143
+ i = min(j + 1, n)
144
+ last_significant = "/"
145
+ continue
146
+ if not c.isspace():
147
+ if c.isalnum() or c in "_$":
148
+ k = i
149
+ while k < n and (source[k].isalnum() or source[k] in "_$"):
150
+ k += 1
151
+ word = source[i:k]
152
+ last_significant = "return" if word == "return" else word[-1]
153
+ i = k
154
+ continue
155
+ last_significant = c
156
+ i += 1
157
+ return "".join(out)
158
+
159
+
160
+ def _mask_template(source: str, out: list[str], start: int, depths: list[int], *, resume: bool = False) -> int:
161
+ """Blank a template literal from ``start`` (a backtick, or the ``}``
162
+ closing an expression when resuming). Returns the index after it."""
163
+ n = len(source)
164
+ i = start + 1
165
+ if resume:
166
+ out[start] = " " # the ``}`` that closed the expression
167
+ while i < n:
168
+ c = source[i]
169
+ if c == "\\":
170
+ out[i] = " "
171
+ if i + 1 < n and source[i + 1] not in "\r\n":
172
+ out[i + 1] = " "
173
+ i += 2
174
+ continue
175
+ if c == "`":
176
+ return i + 1
177
+ if c == "$" and i + 1 < n and source[i + 1] == "{":
178
+ out[i] = " "
179
+ out[i + 1] = " "
180
+ depths.append(0)
181
+ return i + 2
182
+ if c not in "\r\n":
183
+ out[i] = " "
184
+ i += 1
185
+ return n
186
+
187
+
188
+ def _matching(text: str, open_index: int) -> int:
189
+ """Index of the bracket closing the one at ``open_index``, or -1."""
190
+ pairs = {"(": ")", "[": "]", "{": "}", "<": ">"}
191
+ stack = [pairs[text[open_index]]]
192
+ i = open_index + 1
193
+ n = len(text)
194
+ while i < n:
195
+ c = text[i]
196
+ if c in pairs:
197
+ stack.append(pairs[c])
198
+ elif c in ")]}>":
199
+ if c == stack[-1]:
200
+ stack.pop()
201
+ if not stack:
202
+ return i
203
+ elif c == ">":
204
+ pass # an arrow or comparison inside a type; not a bracket
205
+ else:
206
+ return -1
207
+ i += 1
208
+ return -1
209
+
210
+
211
+ def _skip_space(text: str, i: int) -> int:
212
+ n = len(text)
213
+ while i < n and text[i].isspace():
214
+ i += 1
215
+ return i
216
+
217
+
218
+ def _body_after_signature(text: str, i: int) -> tuple[int, int] | None:
219
+ """Given ``i`` at ``(`` or ``<`` of a signature, find the body braces.
220
+
221
+ Returns (open_brace, close_brace) or None when there is no body (an
222
+ overload, an abstract method, a call).
223
+ """
224
+ if i < len(text) and text[i] == "<":
225
+ close = _matching(text, i)
226
+ if close < 0:
227
+ return None
228
+ i = _skip_space(text, close + 1)
229
+ if i >= len(text) or text[i] != "(":
230
+ return None
231
+ close = _matching(text, i)
232
+ if close < 0:
233
+ return None
234
+ i = _skip_space(text, close + 1)
235
+ if i < len(text) and text[i] == ":":
236
+ i = _skip_type(text, i + 1)
237
+ return _block_at(text, i)
238
+
239
+
240
+ def _skip_type(text: str, i: int) -> int:
241
+ """Skip a type annotation, returning the index of the body's ``{`` or
242
+ ``len(text)`` when the signature has no body.
243
+
244
+ Types can contain braces (object types), so the first depth-zero ``{``
245
+ is not necessarily the body. What follows its matching ``}`` decides:
246
+ another ``{`` means the first was a type and the body comes next; a
247
+ type continuation (``|``, ``&``, ``[]``, ``=>``) means keep skipping;
248
+ a terminator (``;`` ``,`` ``)`` ``>`` ``=``) means the signature ended
249
+ without a body, as in an interface method returning ``{}``.
250
+ """
251
+ n = len(text)
252
+ depth = 0
253
+ while i < n:
254
+ c = text[i]
255
+ if c in "([<":
256
+ depth += 1
257
+ elif c in ")]>":
258
+ depth -= 1
259
+ elif c == "{" and depth == 0:
260
+ close = _matching(text, i)
261
+ if close < 0:
262
+ return n
263
+ after = _skip_space(text, close + 1)
264
+ following = text[after] if after < n else ""
265
+ if following == "{":
266
+ return after
267
+ if following in ("|", "&", "[") or text.startswith("=>", after):
268
+ i = close + 1
269
+ continue
270
+ if following in (";", ",", ")", ">", "="):
271
+ return n
272
+ return i
273
+ elif c == ";" and depth == 0:
274
+ return n
275
+ i += 1
276
+ return n
277
+
278
+
279
+ def _block_at(text: str, i: int) -> tuple[int, int] | None:
280
+ if i >= len(text) or text[i] != "{":
281
+ return None
282
+ close = _matching(text, i)
283
+ if close < 0:
284
+ return None
285
+ return i, close
286
+
287
+
288
+ def _is_unexplained_empty_block(original: str, masked: str, open_brace: int, close_brace: int) -> bool:
289
+ """Empty in the source itself, not merely empty once comments are blanked."""
290
+ return masked[open_brace + 1:close_brace].strip() == "" and original[open_brace + 1:close_brace].strip() == ""
291
+
292
+
293
+ def _line_of(text: str, index: int) -> int:
294
+ return text.count("\n", 0, index) + 1
295
+
296
+
297
+ def empty_functions(source: str) -> list[tuple[int, str]]:
298
+ """(line, name) for every function whose body does nothing."""
299
+ if any(len(line) > MINIFIED_LINE_LENGTH for line in source.split("\n")):
300
+ return []
301
+ original = source.replace("\r\n", "\n").replace("\r", "\n")
302
+ text = mask_source(original)
303
+ found: dict[int, tuple[int, str]] = {}
304
+
305
+ for match in _FUNCTION_DECL.finditer(text):
306
+ name = match.group(2) or _expression_name(text, match.start())
307
+ if name is None:
308
+ continue
309
+ body = _body_after_signature(text, match.end())
310
+ if body and _is_unexplained_empty_block(original, text, *body):
311
+ found.setdefault(body[0], (_line_of(text, match.start()), name))
312
+
313
+ for match in _METHOD_LINE.finditer(text):
314
+ name = match.group(2)
315
+ if name in NOT_A_METHOD or FRAMEWORK_HOOK.match(name):
316
+ continue
317
+ body = _body_after_signature(text, match.end())
318
+ if body and _is_unexplained_empty_block(original, text, *body):
319
+ found.setdefault(body[0], (_line_of(text, match.start(2)), name))
320
+
321
+ for match in _EXPORTED_ARROW.finditer(text):
322
+ name = match.group(1)
323
+ if NOOP_NAME.match(name):
324
+ continue
325
+ body = _arrow_body(text, match.end())
326
+ if body and _is_unexplained_empty_block(original, text, *body):
327
+ found.setdefault(body[0], (_line_of(text, match.start()), name))
328
+
329
+ return sorted(found.values())
330
+
331
+
332
+ _EXPORT_DEFAULT_BEFORE = re.compile(r"export\s+default\s*$")
333
+ _EXPORTED_BINDING_BEFORE = re.compile(r"\bexport\s+(?:const|let|var)\s+(" + _IDENT + r")\s*(?::[^=;]*)?=\s*$")
334
+
335
+
336
+ def _expression_name(text: str, function_index: int) -> str | None:
337
+ """Name for an anonymous ``function`` expression, following the arrow
338
+ rule: ``export default`` and exported bindings count, callbacks and
339
+ local bindings do not, and an exported noop is exempt."""
340
+ before = text[max(0, function_index - 200):function_index]
341
+ if _EXPORT_DEFAULT_BEFORE.search(before):
342
+ return "default"
343
+ binding = _EXPORTED_BINDING_BEFORE.search(before)
344
+ if binding and not NOOP_NAME.match(binding.group(1)):
345
+ return binding.group(1)
346
+ return None
347
+
348
+
349
+ def _arrow_body(text: str, i: int) -> tuple[int, int] | None:
350
+ """From just after an exported binding's name, the block of the arrow
351
+ function the binding is initialised with, or None when the initialiser
352
+ is not itself an arrow function (a call, a ternary, an object).
353
+
354
+ Shape accepted: ``[: type] = [async] [<generics>] (params) | ident
355
+ [: return type] => {``. Anything else is not this rule's business.
356
+ """
357
+ n = len(text)
358
+ depth = 0
359
+ while i < n:
360
+ c = text[i]
361
+ if c in "([{":
362
+ depth += 1
363
+ elif c in ")]}":
364
+ depth -= 1
365
+ elif c == ";" and depth == 0:
366
+ return None
367
+ elif c == "=" and depth == 0:
368
+ if text.startswith("=>", i): # an arrow inside the binding's type annotation
369
+ i += 2
370
+ continue
371
+ break
372
+ i += 1
373
+ else:
374
+ return None
375
+ j = _skip_space(text, i + 1)
376
+ if text.startswith("async", j) and j + 5 < n and text[j + 5].isspace():
377
+ j = _skip_space(text, j + 5)
378
+ if j < n and text[j] == "<":
379
+ close = _matching(text, j)
380
+ if close < 0:
381
+ return None
382
+ j = _skip_space(text, close + 1)
383
+ if j < n and text[j] == "(":
384
+ close = _matching(text, j)
385
+ if close < 0:
386
+ return None
387
+ j = _skip_space(text, close + 1)
388
+ else:
389
+ ident = _IDENT_AT.match(text, j)
390
+ if not ident:
391
+ return None
392
+ j = _skip_space(text, ident.end())
393
+ if j < n and text[j] == ":":
394
+ arrow = _return_type_end(text, j + 1)
395
+ if arrow is None:
396
+ return None
397
+ j = arrow
398
+ if not text.startswith("=>", j):
399
+ return None
400
+ return _block_at(text, _skip_space(text, j + 2))
401
+
402
+
403
+ def _return_type_end(text: str, i: int) -> int | None:
404
+ """Index of the ``=>`` that ends an arrow function's return type, or
405
+ None when the statement ends first."""
406
+ n = len(text)
407
+ depth = 0
408
+ while i < n:
409
+ c = text[i]
410
+ if text.startswith("=>", i):
411
+ if depth == 0:
412
+ return i
413
+ i += 2
414
+ continue
415
+ if c in "([{<":
416
+ depth += 1
417
+ elif c in ")]}>":
418
+ depth -= 1
419
+ elif c == ";" and depth == 0:
420
+ return None
421
+ i += 1
422
+ return None
countersign/pack.py ADDED
@@ -0,0 +1,243 @@
1
+ # audited on 20260903
2
+ """The evidence pack: one self-contained HTML file per run.
3
+
4
+ This is the artifact someone hands to someone else: a lead engineer
5
+ reviewing an agent's pull request, an agency delivering to a client, an
6
+ auditor asking what was actually checked. It has to say four things without
7
+ being asked: what was checked, how, what was found, and what was not
8
+ covered.
9
+
10
+ It has no external assets, so it opens anywhere and prints to PDF from any
11
+ browser.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+ from .claims import PASS
22
+ from .engine import FAIL_VERDICT, TEST_EXCLUSION_NOTE, GateResult
23
+ from .plain import plain_sentences
24
+ from .receipt import commit_label
25
+ from .stubscan import RULES
26
+
27
+ NOT_COVERED_ALWAYS: tuple[str, ...] = (
28
+ "This pack reports deterministic checks run in this repository, on this machine, at the "
29
+ "time stated. It is not a security audit, not a code review, and not a statement of "
30
+ "fitness for any purpose.",
31
+ "Only the claims declared in the claims file were verified. Anything nobody declared was "
32
+ "not checked.",
33
+ "Claim commands are the declaring team's own commands, run exactly as declared. Whether "
34
+ "they are deterministic, and whether they test what their statement says, is the team's "
35
+ "responsibility; Countersign records what they did.",
36
+ "Line exemptions are honored in the source itself and counted here. Each one is a human "
37
+ "decision that a finding was a false positive; audit them like any other review decision.",
38
+ "The register this pack cites lives on the machine that ran the checks. It proves that no "
39
+ "entry was altered after it was written by anyone who did not also rewrite every entry after "
40
+ "it. On its own that is evidence against accident and against third parties, not against "
41
+ "the machine's owner; evidence against the owner requires the register head to be anchored "
42
+ "outside the machine.",
43
+ )
44
+
45
+ NOT_COVERED_TESTS_EXCLUDED = (
46
+ "Test files were excluded from the marker scan by policy (exclude_tests = true): test "
47
+ "code legitimately fabricates data. Unfinished work inside test files was not looked for."
48
+ )
49
+
50
+
51
+ def _esc(value: object) -> str:
52
+ return html.escape(str(value))
53
+
54
+
55
+ STYLE = """
56
+ :root { color-scheme: light; }
57
+ * { box-sizing: border-box; }
58
+ body { margin: 0; padding: 48px; background: #E9E8E2; color: #121410;
59
+ font: 15px/1.55 "Helvetica Neue", Arial, sans-serif; }
60
+ .sheet { max-width: 920px; margin: 0 auto; background: #F2F1EC; border: 1px solid #D3D2C8;
61
+ border-radius: 10px; padding: 40px; }
62
+ h1 { font-size: 26px; letter-spacing: -0.02em; margin: 0 0 4px; }
63
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.18em; color: #6E7168;
64
+ margin: 36px 0 12px; font-weight: 600; }
65
+ .mono { font-family: "SFMono-Regular", Menlo, Consolas, monospace; font-size: 12px; }
66
+ .faint { color: #6E7168; }
67
+ .meta { display: grid; grid-template-columns: 210px 1fr; gap: 6px 16px; margin-top: 8px; }
68
+ .meta span:nth-child(2n) { word-break: break-all; }
69
+ table { width: 100%; border-collapse: collapse; margin-top: 8px; }
70
+ th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid #D3D2C8; vertical-align: top; }
71
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #6E7168; font-weight: 600; }
72
+ tr:last-child td { border-bottom: none; }
73
+ .verdict-pass { color: #1E5B41; font-weight: 700; }
74
+ .verdict-fail { color: #C2402A; font-weight: 700; }
75
+ .counts { display: flex; gap: 28px; margin-top: 10px; }
76
+ .count b { display: block; font-size: 30px; letter-spacing: -0.02em; }
77
+ .note { border-left: 3px solid #1E5B41; padding: 4px 0 4px 14px; margin-top: 10px; color: #3C3F38; }
78
+ .chain { background: #E9E8E2; border: 1px solid #D3D2C8; border-radius: 6px; padding: 12px;
79
+ word-break: break-all; }
80
+ footer { max-width: 920px; margin: 18px auto 0; color: #6E7168; font-size: 12px; }
81
+ @media print { body { padding: 0; background: #fff; } .sheet { border: none; background: #fff; } }
82
+ """
83
+
84
+
85
+ def _esc_lines(items: tuple[str, ...]) -> str:
86
+ return "\n".join(f" <li>{_esc(item)}</li>" for item in items)
87
+
88
+
89
+ def build_pack(result: GateResult, path: Path) -> Path:
90
+ generated = datetime.now(timezone.utc)
91
+ verdict_class = "verdict-pass" if result.verdict != FAIL_VERDICT else "verdict-fail"
92
+ verdict_word = "COUNTERSIGNED" if result.verdict != FAIL_VERDICT else "NOT COUNTERSIGNED"
93
+
94
+ finding_rows = "\n".join(
95
+ f"""<tr>
96
+ <td class="mono">{_esc(f.path)}:{_esc(f.line)}</td>
97
+ <td class="mono">{_esc(f.rule_id)}</td>
98
+ <td>{_esc(f.why)}</td>
99
+ <td class="mono faint">{_esc(f.evidence)}</td>
100
+ </tr>"""
101
+ for f in result.findings
102
+ ) or '<tr><td colspan="4" class="faint">No marker findings.</td></tr>'
103
+
104
+ if result.claim_results is None:
105
+ claim_rows = '<tr><td colspan="5" class="faint">The claims check was skipped, not passed. The notes below say why.</td></tr>'
106
+ else:
107
+ claim_rows = "\n".join(
108
+ f"""<tr>
109
+ <td class="mono {_esc('verdict-pass' if c.status == PASS else 'verdict-fail')}">{_esc(c.status.upper())}</td>
110
+ <td>{_esc(c.statement)}</td>
111
+ <td class="mono">{_esc(c.command or 'none declared')}</td>
112
+ <td class="mono">{_esc(c.exit_code if c.exit_code is not None else 'n/a')}</td>
113
+ <td class="mono faint">{_esc(f"{c.duration_ms} ms")}</td>
114
+ </tr>"""
115
+ for c in result.claim_results
116
+ )
117
+
118
+ method_rows = "\n".join(
119
+ f"""<tr>
120
+ <td class="mono">{_esc(rule.rule_id)}</td>
121
+ <td>{_esc(rule.why)}</td>
122
+ <td class="mono">{_esc(len([f for f in result.findings if f.rule_id == rule.rule_id]))}</td>
123
+ </tr>"""
124
+ for rule in RULES
125
+ ) + """
126
+ <tr><td class="mono">empty-body</td><td>functions whose body does nothing and explains nothing: a bare pass, ellipsis or empty braces with no docstring or comment (structural: Python via ast, TypeScript and JavaScript via a comment-and-string-aware scan)</td>
127
+ <td class="mono">{}</td></tr>
128
+ <tr><td class="mono">unparseable</td><td>Python files the parser rejects (Python, structural)</td>
129
+ <td class="mono">{}</td></tr>
130
+ <tr><td class="mono">claims</td><td>each declared claim's disproof command was executed and judged as declared; a claim the config requires but the file omits counts as failed</td>
131
+ <td class="mono">{}</td></tr>
132
+ <tr><td class="mono">claims-diff</td><td>claims compared with the base revision, when one was given; weakened claims counted here</td>
133
+ <td class="mono">{}</td></tr>
134
+ """.format(
135
+ len([f for f in result.findings if f.rule_id == "empty-body"]),
136
+ len([f for f in result.findings if f.rule_id == "unparseable"]),
137
+ len(result.failed_claims),
138
+ len(result.weakened_claims) if result.claims_diff is not None else "n/a",
139
+ )
140
+
141
+ if result.claims_diff is None:
142
+ diff_section = ""
143
+ else:
144
+ diff_rows = "\n".join(
145
+ f"""<tr>
146
+ <td class="mono">{_esc(c.claim_id)}</td>
147
+ <td class="mono">{_esc(c.kind)}</td>
148
+ <td class="mono {_esc('verdict-fail' if c.weakened else '')}">{_esc('YES' if c.weakened else 'no')}</td>
149
+ <td class="mono faint">{_esc(c.detail)}</td>
150
+ </tr>"""
151
+ for c in result.claims_diff
152
+ ) or '<tr><td colspan="4" class="faint">No claim changed.</td></tr>'
153
+ diff_section = f"""
154
+ <h2>Claims changed against {_esc(result.claims_base)}</h2>
155
+ <p class="faint">The claims file was compared with the one at the base revision. A removed claim, a
156
+ changed expectation or a changed needle is a weakening; a changed command is listed for review.</p>
157
+ <table>
158
+ <tr><th>Claim</th><th>Change</th><th>Weakened</th><th>Detail</th></tr>
159
+ {diff_rows}
160
+ </table>
161
+ """
162
+ plain = "\n".join(f" <li>{_esc(sentence)}</li>" for sentence in plain_sentences(result))
163
+ not_covered = NOT_COVERED_ALWAYS + ((NOT_COVERED_TESTS_EXCLUDED,) if result.tests_excluded else ())
164
+ # The test exclusion is stated in the not-covered list above when it
165
+ # applies, so the run note that says the same thing is not repeated.
166
+ notes = "\n".join(f" <li>{_esc(note)}</li>" for note in result.notes if note != TEST_EXCLUSION_NOTE)
167
+
168
+ document = f"""<!doctype html>
169
+ <html lang="en"><head><meta charset="utf-8">
170
+ <title>Countersign evidence pack, {_esc(result.run_id)}</title>
171
+ <style>{STYLE}</style></head>
172
+ <body><div class="sheet">
173
+
174
+ <p class="mono faint">COUNTERSIGN · EVIDENCE PACK</p>
175
+ <h1>Agent work verification</h1>
176
+ <p class="{verdict_class} mono" style="font-size:18px; letter-spacing:0.08em;">{verdict_word}</p>
177
+
178
+ <div class="meta mono">
179
+ <span class="faint">Run reference</span><span>{_esc(result.run_id)}</span>
180
+ <span class="faint">Git commit</span><span>{_esc(commit_label(result))}</span>
181
+ <span class="faint">Generated</span><span>{generated:%d %b %Y %H:%M} UTC</span>
182
+ <span class="faint">Countersign version</span><span>{_esc(__version__)}</span>
183
+ <span class="faint">Files scanned</span><span>{_esc(result.files_scanned)}</span>
184
+ <span class="faint">Config (SHA-256)</span><span>{_esc(result.config_sha256)}</span>
185
+ <span class="faint">Claims file (SHA-256)</span><span>{_esc(result.claims_sha256 or 'none read; the claims check was skipped')}</span>
186
+ </div>
187
+
188
+ <h2>In plain words</h2>
189
+ <ul>
190
+ {plain}
191
+ </ul>
192
+
193
+ <h2>What was found</h2>
194
+ <div class="counts">
195
+ <div class="count"><b class="verdict-fail">{len(result.findings)}</b><span class="mono faint">MARKER FINDINGS</span></div>
196
+ <div class="count"><b>{result.exemptions}</b><span class="mono faint">EXEMPTIONS USED</span></div>
197
+ <div class="count"><b class="verdict-fail">{len(result.failed_claims)}</b><span class="mono faint">FAILED CLAIMS</span></div>
198
+ </div>
199
+ <table>
200
+ <tr><th>Location</th><th>Rule</th><th>Why it is a problem</th><th>Evidence</th></tr>
201
+ {finding_rows}
202
+ </table>
203
+
204
+ <h2>Claims, as declared and as judged</h2>
205
+ <table>
206
+ <tr><th>Status</th><th>Claim</th><th>Disproof command</th><th>Exit</th><th>Duration</th></tr>
207
+ {claim_rows}
208
+ </table>
209
+
210
+ {diff_section}
211
+ <h2>How it was checked</h2>
212
+ <table>
213
+ <tr><th>Check</th><th>What it detects</th><th>Findings</th></tr>
214
+ {method_rows}
215
+ </table>
216
+ <p class="note">The marker scan is deterministic and re-runnable: the same files under the same
217
+ rule versions give the same findings. Claims were run exactly as declared and judged only by
218
+ exit code or output, as declared. No model judgement participates in any verdict on this pack.</p>
219
+
220
+ <h2>What this pack does not cover</h2>
221
+ <ul>
222
+ {_esc_lines(not_covered)}
223
+ </ul>
224
+ {f'<ul>{notes}</ul>' if notes else ''}
225
+
226
+ <h2>Integrity and reproducibility</h2>
227
+ <p class="faint">Every check, finding and claim verdict was written to an append-only register,
228
+ each entry sealed against the one before it. The run recorded the SHA-256 of the config and
229
+ claims files it read, so the same run can be re-derived later from the same files and compared
230
+ result for result with <span class="mono">countersign reproduce</span>.</p>
231
+ <div class="chain mono">
232
+ <div>Register entry: {_esc(result.register_index)}</div>
233
+ <div>Head: {_esc(result.register_hash)}</div>
234
+ </div>
235
+
236
+ </div>
237
+ <footer class="mono">Countersign {_esc(__version__)} · run inside this repository's own environment · Countersign itself made no network requests; claim commands are the declaring team's own</footer>
238
+ </body></html>"""
239
+
240
+ path = Path(path)
241
+ path.parent.mkdir(parents=True, exist_ok=True)
242
+ path.write_text(document, encoding="utf-8")
243
+ return path