diff --git a/docs/a38.md b/docs/a38.md index 288659a..01c01bb 100644 --- a/docs/a38.md +++ b/docs/a38.md @@ -99,7 +99,7 @@ agent a38 verify --policy /tmp/a38-run/policy.json \ Only a complete `run` plus successful local `verify` for the signed, clean final SHA may be recorded as `local_check_pass`. That final SHA must be on the open draft with no intervening commit after the verified measurement. Early draft publication may precede this final measurement ([pull request lifecycle](pull-request-lifecycle.md)). Any fix, amend, rebase, or other new SHA requires a new signed clean commit and a complete run and verification from the beginning. -The generated `report.md` is ready to publish: one short sentence under `EN:`, one under `DE:`, and the unchanged machine-readable block inside a closed `
` section. Labels appear on their own lines; blank lines separate the languages and follow `` so GitHub renders the enclosed Markdown. The summary describes the evidence without declaring failed or interrupted runs successful. Results, commands and durations remain in the collapsed details. Adopters and plugins use this central output rather than maintaining another format template. +The generated `report.md` is ready to publish: one short sentence under `EN:`, one under `DE:`, and a closed `
` section containing a mandatory table followed by the unchanged machine-readable block in its own nested closed details section. Labels appear on their own lines; blank lines separate the languages and follow `` so GitHub renders the enclosed Markdown. The summary describes the evidence without declaring failed or interrupted runs successful. The table lists **every recorded A38 job**, in execution order, with its ID/name, duration, final result and exit code, including failed, errored or timed-out jobs. Display durations in seconds, always rounded **up to whole seconds** (84.467 → 85 s; 84 → 84 s; 0 → 0 s). Preserve the exact fractional measurements in the original machine block. Rows represent A38 jobs, not every underlying test case in a suite. Job names are escaped so they cannot alter the table or HTML structure. Commands and exact evidence remain in the nested original report. Adopters and plugins use this central output rather than maintaining another format template. Post the generated `report.md` unchanged as a PR comment **using the PR author's GitHub account**, preserving its markers and fenced JSON block. For legacy reports, adding this presentation around the original marked block is allowed only if that block remains byte-for-byte unchanged; preserve all measured data and timestamps, then verify and reassess the edited comment. Publishing is separate from running and verification. Do not paste raw logs containing credentials into the comment and do not hand-edit or reconstruct a passing payload. diff --git a/src/agent_cli/a38.py b/src/agent_cli/a38.py index ffd3d1e..a30184e 100644 --- a/src/agent_cli/a38.py +++ b/src/agent_cli/a38.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import html import json import math import os @@ -695,13 +696,34 @@ def _report_from_dict(payload: Mapping[str, Any]) -> LocalCiReport: return parse_comment(block) +def _report_table_cell(value: str) -> str: + # Keep policy-supplied names inside one literal Markdown/HTML table cell. + escaped = html.escape(" ".join(value.split())) + return "".join(f"&#{ord(char)};" if char in "\\|`*_[]{}" else char for char in escaped) + + +def _report_table(report: LocalCiReport) -> str: + rows = [ + "| Check / Prüfung | Duration / Laufzeit | Result / Ergebnis | Exit code |", + "| --- | ---: | --- | ---: |", + ] + for run in report.runs: + label = _report_table_cell(f"{run.id}: {run.name}") + rows.append(f"| {label} | {math.ceil(run.duration_s)} s | {run.result} | {run.exit_code} |") + return "\n".join(rows) + "\n" + + def _write_report(output: Path, payload: Mapping[str, Any]) -> None: report = _report_from_dict(payload) text = ( "EN:\nThe A38 report below records the checks, results and durations.\n\n" "DE:\nDer A38-Bericht unten dokumentiert die Prüfungen, Ergebnisse und Laufzeiten.\n\n" "
\nDetails\n\n" + f"{_report_table(report)}\n" + "Durations rounded up to whole seconds / Laufzeiten auf ganze Sekunden aufgerundet.\n\n" + "
\nOriginal report / Originalbericht\n\n" f"{render_block(report)}\n" + "
\n\n" "
\n" ) _write_bytes_atomic(output, text.encode("utf-8")) diff --git a/tests/test_a38.py b/tests/test_a38.py index 47c6daa..74ca7d5 100644 --- a/tests/test_a38.py +++ b/tests/test_a38.py @@ -419,7 +419,9 @@ def test_presentation_preserves_evidence_and_validation_for_all_outcomes(self) - "DE:\nDer A38-Bericht unten dokumentiert die Prüfungen, Ergebnisse und Laufzeiten.\n\n" )) # Only the presentation changes; the entire original evidence remains intact. - self.assertEqual(details, render_block(parse_comment(original)) + "\n
\n") + table, original_details = details.split("
\nOriginal report / Originalbericht\n\n") + self.assertIn(f"| unit: Unit | 1 s | {result} | {code} |", table) + self.assertEqual(original_details, render_block(parse_comment(original)) + "\n
\n\n
\n") self.assertNotIn("
None: + runs = [ + _run_payload(ident="zero", name="Zero", duration_s=0), + _run_payload(ident="whole", name="Whole", duration_s=84, result="fail", exit_code=1), + _run_payload(ident="fraction", name="Fraction", duration_s=84.467, result="error", exit_code=127), + _run_payload(ident="tiny", name="Tiny", duration_s=0.001, result="timeout", exit_code=124), + ] + original = _report_comment(required=[r["id"] for r in runs], runs=runs) + payload = json.loads(original.split("```json\n", 1)[1].split("```", 1)[0]) + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "report.md" + _write_report(output, payload) + comment = output.read_text() + rows = [line for line in comment.splitlines() if line.startswith("| ")][2:] + self.assertEqual(rows, [ + "| zero: Zero | 0 s | pass | 0 |", + "| whole: Whole | 84 s | fail | 1 |", + "| fraction: Fraction | 85 s | error | 127 |", + "| tiny: Tiny | 1 s | timeout | 124 |", + ]) + self.assertEqual(parse_comment(comment), parse_comment(original)) + self.assertIn(render_block(parse_comment(original)), comment) + + def test_policy_name_cannot_inject_table_rows_or_html(self) -> None: + name = "Checks | `code`\n
[link](url) *bold* \\" + original = _report_comment(runs=[_run_payload(name=name)]) + payload = json.loads(original.split("```json\n", 1)[1].split("```", 1)[0]) + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "report.md" + _write_report(output, payload) + comment = output.read_text() + table = comment.split("Details\n\n", 1)[1].split("
", 1)[0] + rows = [line for line in table.splitlines() if line.startswith("| ")] + self.assertEqual(len(rows), 3) + self.assertEqual(rows[2].count("|"), 5) + for fragment in ("
", "