claude-dev-env 1.59.0 → 1.60.0
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.
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.mjs +128 -6
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -1,65 +1,86 @@
|
|
|
1
|
-
"""Render a convergence
|
|
1
|
+
"""Render a visual convergence summary HTML report from an autoconverge journal."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import html
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
-
import subprocess
|
|
8
7
|
import sys
|
|
9
8
|
from dataclasses import dataclass
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
from typing import TextIO
|
|
12
11
|
|
|
13
12
|
from autoconverge_report_constants.render_report_constants import (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
CATEGORY_BUG,
|
|
14
|
+
CATEGORY_LABEL_BY_VALUE,
|
|
15
|
+
CATEGORY_SORT_ORDER,
|
|
16
|
+
CAUSE_MUTED_STYLE,
|
|
17
|
+
DEFAULT_FINDING_CATEGORY,
|
|
18
|
+
DEFAULT_FINDING_SEVERITY,
|
|
20
19
|
GITHUB_PR_URL_TEMPLATE,
|
|
21
20
|
HTML_DOCTYPE,
|
|
22
21
|
HTML_HEAD_TEMPLATE,
|
|
23
22
|
HTML_STYLE_BLOCK,
|
|
23
|
+
ISO_DATE_LENGTH,
|
|
24
|
+
ISSUE_CLASS_FIELD_AFTER_LINES,
|
|
25
|
+
ISSUE_CLASS_FIELD_BEFORE_LINES,
|
|
26
|
+
ISSUE_CLASS_FIELD_CATEGORY,
|
|
27
|
+
ISSUE_CLASS_FIELD_CAUSE,
|
|
28
|
+
ISSUE_CLASS_FIELD_COUNT,
|
|
29
|
+
ISSUE_CLASS_FIELD_MEDIUM,
|
|
30
|
+
ISSUE_CLASS_FIELD_PLAINNAME,
|
|
31
|
+
ISSUE_CLASS_FIELD_SEVERITY,
|
|
32
|
+
ISSUE_CLASS_FIELD_STATUS,
|
|
24
33
|
JOURNAL_SIBLING_SUBAGENTS,
|
|
25
34
|
JOURNAL_SIBLING_WORKFLOWS,
|
|
35
|
+
LABEL_CONVERGENCE_SUMMARY,
|
|
26
36
|
LABEL_COPILOT_GATE,
|
|
27
37
|
LABEL_PREFIX_FIX,
|
|
28
38
|
LABEL_PREFIX_LENS,
|
|
29
39
|
LABEL_RESOLVE_HEAD,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
MEDIUM_CODE,
|
|
41
|
+
MEDIUM_TERMINAL,
|
|
42
|
+
SCENE_FIELD_CAPTION,
|
|
43
|
+
SCENE_FIELD_CONDITION,
|
|
44
|
+
SCENE_FIELD_RESULT,
|
|
45
|
+
SCENE_FIELD_TRIGGER,
|
|
46
|
+
SEVERITY_SORT_RANK,
|
|
47
|
+
SHORT_SHA_LENGTH,
|
|
48
|
+
STATUS_LABEL_BY_VALUE,
|
|
34
49
|
STRUCTURED_OUTPUT_TOOL_NAME,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
SUMMARY_FIELD_FIX_SCENES,
|
|
51
|
+
SUMMARY_FIELD_ISSUE_CLASSES,
|
|
52
|
+
SUMMARY_FIELD_PR_FIX,
|
|
53
|
+
SUMMARY_FIELD_PR_PROBLEM,
|
|
54
|
+
SUMMARY_FIELD_PROBLEM_SCENES,
|
|
55
|
+
SUMMARY_FIELD_VERDICT_LINE,
|
|
56
|
+
TIMELINE_AFTER_LABEL,
|
|
57
|
+
TIMELINE_AFTER_PILL,
|
|
58
|
+
TIMELINE_BEFORE_LABEL,
|
|
59
|
+
TIMELINE_BEFORE_PILL,
|
|
60
|
+
TIMELINE_TERMINAL_BAR_LABEL,
|
|
39
61
|
)
|
|
40
62
|
|
|
41
63
|
|
|
42
64
|
@dataclass(frozen=True)
|
|
43
65
|
class RawFinding:
|
|
44
|
-
"""A single finding from a lens or copilot-gate agent result
|
|
66
|
+
"""A single finding from a lens or copilot-gate agent result."""
|
|
45
67
|
|
|
46
68
|
file: str
|
|
47
69
|
line: int
|
|
48
70
|
severity: str
|
|
49
71
|
title: str
|
|
50
72
|
detail: str
|
|
51
|
-
|
|
52
|
-
sha: str
|
|
73
|
+
category: str
|
|
53
74
|
|
|
54
75
|
|
|
55
76
|
@dataclass(frozen=True)
|
|
56
77
|
class FixRecord:
|
|
57
|
-
"""The structured result of a fix agent, with
|
|
78
|
+
"""The structured result of a fix agent, with base-sha context attached."""
|
|
58
79
|
|
|
59
80
|
new_sha: str
|
|
60
81
|
pushed: bool
|
|
61
82
|
resolved_without_commit: bool
|
|
62
|
-
|
|
83
|
+
summary: str
|
|
63
84
|
base_sha: str
|
|
64
85
|
|
|
65
86
|
|
|
@@ -81,15 +102,10 @@ class RunData:
|
|
|
81
102
|
|
|
82
103
|
generated_date: str
|
|
83
104
|
total_finding_count: int
|
|
84
|
-
critical_finding_count: int
|
|
85
|
-
minor_finding_count: int
|
|
86
105
|
fix_commit_count: int
|
|
87
|
-
|
|
88
|
-
finding_count_by_round: dict[int, int]
|
|
89
|
-
finding_count_by_theme: dict[str, int]
|
|
90
|
-
all_critical_findings: list[RawFinding]
|
|
91
|
-
all_minor_findings: list[RawFinding]
|
|
106
|
+
all_distinct_findings: list[RawFinding]
|
|
92
107
|
fix_by_round: dict[int, FixRecord]
|
|
108
|
+
convergence_summary: dict | None
|
|
93
109
|
|
|
94
110
|
|
|
95
111
|
def _resolve_agents_dir(journal_path: Path) -> Path:
|
|
@@ -167,58 +183,6 @@ def _last_structured_input_in_line(
|
|
|
167
183
|
return latest_input
|
|
168
184
|
|
|
169
185
|
|
|
170
|
-
def _derive_theme(file_path: str) -> str:
|
|
171
|
-
"""Return the first two slash-separated segments of a file path.
|
|
172
|
-
|
|
173
|
-
Args:
|
|
174
|
-
file_path: A relative file path string from a finding.
|
|
175
|
-
|
|
176
|
-
Returns:
|
|
177
|
-
A theme string like 'src/exports', the whole path when fewer than two
|
|
178
|
-
segments exist, or THEME_FALLBACK when the path is empty.
|
|
179
|
-
"""
|
|
180
|
-
if not file_path:
|
|
181
|
-
return THEME_FALLBACK
|
|
182
|
-
segments = file_path.split("/")
|
|
183
|
-
return "/".join(segments[:THEME_PATH_SEGMENT_COUNT])
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def _count_tests_added(base_sha: str, new_sha: str, repo_path: Path) -> int:
|
|
187
|
-
"""Count new test definitions introduced between two commits.
|
|
188
|
-
|
|
189
|
-
Args:
|
|
190
|
-
base_sha: The commit sha the round reviewed.
|
|
191
|
-
new_sha: The sha produced by the fix commit.
|
|
192
|
-
repo_path: Path to the git repository root.
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
Number of newly added test-function definitions; 0 on any git error.
|
|
196
|
-
"""
|
|
197
|
-
diff_command = [
|
|
198
|
-
"git",
|
|
199
|
-
"-C",
|
|
200
|
-
str(repo_path),
|
|
201
|
-
"diff",
|
|
202
|
-
f"{base_sha}..{new_sha}",
|
|
203
|
-
"--",
|
|
204
|
-
*TEST_PATH_GLOBS,
|
|
205
|
-
]
|
|
206
|
-
try:
|
|
207
|
-
completed = subprocess.run(
|
|
208
|
-
diff_command,
|
|
209
|
-
capture_output=True,
|
|
210
|
-
text=True,
|
|
211
|
-
check=True,
|
|
212
|
-
)
|
|
213
|
-
except (subprocess.SubprocessError, OSError):
|
|
214
|
-
return 0
|
|
215
|
-
|
|
216
|
-
diff_text = completed.stdout
|
|
217
|
-
test_def_pattern = re.compile(TEST_DEFINITION_PATTERN, re.MULTILINE)
|
|
218
|
-
|
|
219
|
-
return len(test_def_pattern.findall(diff_text))
|
|
220
|
-
|
|
221
|
-
|
|
222
186
|
def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, str]:
|
|
223
187
|
"""Return a deduplication key for a finding.
|
|
224
188
|
|
|
@@ -233,13 +197,11 @@ def _build_dedup_key(file_path: str, line: int, title: str) -> tuple[str, int, s
|
|
|
233
197
|
return (file_path, line, title.lower())
|
|
234
198
|
|
|
235
199
|
|
|
236
|
-
def _parse_finding_from_dict(raw: dict
|
|
200
|
+
def _parse_finding_from_dict(raw: dict) -> RawFinding:
|
|
237
201
|
"""Construct a RawFinding from a raw agent result dict.
|
|
238
202
|
|
|
239
203
|
Args:
|
|
240
204
|
raw: The raw finding dict from the agent result.
|
|
241
|
-
round_number: The round this finding belongs to.
|
|
242
|
-
sha: The commit sha this finding was discovered on.
|
|
243
205
|
|
|
244
206
|
Returns:
|
|
245
207
|
A RawFinding dataclass instance.
|
|
@@ -247,22 +209,18 @@ def _parse_finding_from_dict(raw: dict, round_number: int, sha: str) -> RawFindi
|
|
|
247
209
|
return RawFinding(
|
|
248
210
|
file=raw.get("file", ""),
|
|
249
211
|
line=raw.get("line", 0),
|
|
250
|
-
severity=raw.get("severity",
|
|
212
|
+
severity=raw.get("severity", DEFAULT_FINDING_SEVERITY),
|
|
251
213
|
title=raw.get("title", ""),
|
|
252
214
|
detail=raw.get("detail", ""),
|
|
253
|
-
|
|
254
|
-
sha=sha,
|
|
215
|
+
category=raw.get("category", DEFAULT_FINDING_CATEGORY),
|
|
255
216
|
)
|
|
256
217
|
|
|
257
218
|
|
|
258
|
-
def _parse_fix_record(
|
|
259
|
-
agent_result: dict, round_number: int, base_sha: str
|
|
260
|
-
) -> FixRecord:
|
|
219
|
+
def _parse_fix_record(agent_result: dict, base_sha: str) -> FixRecord:
|
|
261
220
|
"""Construct a FixRecord from a fix agent's structured output.
|
|
262
221
|
|
|
263
222
|
Args:
|
|
264
223
|
agent_result: The structured output dict from the fix agent.
|
|
265
|
-
round_number: The round this fix belongs to.
|
|
266
224
|
base_sha: The HEAD sha the round reviewed before fixing.
|
|
267
225
|
|
|
268
226
|
Returns:
|
|
@@ -272,7 +230,7 @@ def _parse_fix_record(
|
|
|
272
230
|
new_sha=agent_result.get("newSha", ""),
|
|
273
231
|
pushed=bool(agent_result.get("pushed", False)),
|
|
274
232
|
resolved_without_commit=bool(agent_result.get("resolvedWithoutCommit", False)),
|
|
275
|
-
|
|
233
|
+
summary=agent_result.get("summary", ""),
|
|
276
234
|
base_sha=base_sha,
|
|
277
235
|
)
|
|
278
236
|
|
|
@@ -322,13 +280,11 @@ def _parse_progress_entries(
|
|
|
322
280
|
current_round_base_sha = sha
|
|
323
281
|
raw_findings: list[dict] = agent_result.get("findings", [])
|
|
324
282
|
for each_raw in raw_findings:
|
|
325
|
-
all_findings.append(
|
|
326
|
-
_parse_finding_from_dict(each_raw, current_round, sha)
|
|
327
|
-
)
|
|
283
|
+
all_findings.append(_parse_finding_from_dict(each_raw))
|
|
328
284
|
|
|
329
285
|
if is_fix:
|
|
330
286
|
fix_by_round[current_round] = _parse_fix_record(
|
|
331
|
-
agent_result,
|
|
287
|
+
agent_result, current_round_base_sha
|
|
332
288
|
)
|
|
333
289
|
|
|
334
290
|
return all_findings, fix_by_round
|
|
@@ -355,19 +311,45 @@ def _dedup_findings(all_findings: list[RawFinding]) -> list[RawFinding]:
|
|
|
355
311
|
return distinct
|
|
356
312
|
|
|
357
313
|
|
|
358
|
-
def
|
|
314
|
+
def _extract_convergence_summary(
|
|
315
|
+
progress_entries: list[dict], agents_dir: Path
|
|
316
|
+
) -> dict | None:
|
|
317
|
+
"""Return the convergence-summary StructuredOutput, or None when absent.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
progress_entries: The workflowProgress list from the journal.
|
|
321
|
+
agents_dir: Directory containing per-agent .jsonl transcript files.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
The summarizer agent's last StructuredOutput input dict, or None when
|
|
325
|
+
no convergence-summary entry exists or its transcript is unreadable.
|
|
326
|
+
"""
|
|
327
|
+
for each_entry in progress_entries:
|
|
328
|
+
if each_entry.get("label") != LABEL_CONVERGENCE_SUMMARY:
|
|
329
|
+
continue
|
|
330
|
+
agent_id = each_entry.get("agentId")
|
|
331
|
+
if not agent_id:
|
|
332
|
+
return None
|
|
333
|
+
transcript_path = agents_dir / f"agent-{agent_id}.jsonl"
|
|
334
|
+
return _extract_structured_output(transcript_path)
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def load_run_data(journal_path: Path) -> RunData:
|
|
359
339
|
"""Parse a workflow journal and its agent transcripts into aggregated metrics.
|
|
360
340
|
|
|
361
341
|
Args:
|
|
362
342
|
journal_path: Path to the wf_<runId>.json journal file.
|
|
363
|
-
repo_path: Path to the git repository for counting tests added.
|
|
364
343
|
|
|
365
344
|
Returns:
|
|
366
|
-
A RunData instance with
|
|
345
|
+
A RunData instance with finding lists, fix records, and the summary populated.
|
|
367
346
|
"""
|
|
347
|
+
iso_date_length = ISO_DATE_LENGTH
|
|
368
348
|
journal = json.loads(journal_path.read_text(encoding="utf-8"))
|
|
369
349
|
timestamp: str = journal.get("timestamp", "")
|
|
370
|
-
generated_date =
|
|
350
|
+
generated_date = (
|
|
351
|
+
timestamp[:iso_date_length] if len(timestamp) >= iso_date_length else ""
|
|
352
|
+
)
|
|
371
353
|
|
|
372
354
|
progress_entries: list[dict] = journal.get("workflowProgress", [])
|
|
373
355
|
agents_dir = _resolve_agents_dir(journal_path)
|
|
@@ -376,305 +358,495 @@ def load_run_data(journal_path: Path, repo_path: Path) -> RunData:
|
|
|
376
358
|
progress_entries, agents_dir
|
|
377
359
|
)
|
|
378
360
|
distinct_findings = _dedup_findings(all_raw_findings)
|
|
379
|
-
|
|
380
|
-
all_critical_findings: list[RawFinding] = []
|
|
381
|
-
all_minor_findings: list[RawFinding] = []
|
|
382
|
-
finding_count_by_round: dict[int, int] = {}
|
|
383
|
-
finding_count_by_theme: dict[str, int] = {}
|
|
384
|
-
|
|
385
|
-
for each_finding in distinct_findings:
|
|
386
|
-
severity = each_finding.severity
|
|
387
|
-
round_number = each_finding.round_number
|
|
388
|
-
theme = _derive_theme(each_finding.file)
|
|
389
|
-
|
|
390
|
-
if severity in SEVERITY_CRITICAL_LEVELS:
|
|
391
|
-
all_critical_findings.append(each_finding)
|
|
392
|
-
else:
|
|
393
|
-
all_minor_findings.append(each_finding)
|
|
394
|
-
|
|
395
|
-
finding_count_by_round[round_number] = (
|
|
396
|
-
finding_count_by_round.get(round_number, 0) + 1
|
|
397
|
-
)
|
|
398
|
-
finding_count_by_theme[theme] = finding_count_by_theme.get(theme, 0) + 1
|
|
361
|
+
convergence_summary = _extract_convergence_summary(progress_entries, agents_dir)
|
|
399
362
|
|
|
400
363
|
fix_commit_count = sum(1 for each_fix in fix_by_round.values() if each_fix.pushed)
|
|
401
364
|
|
|
402
|
-
tests_added_by_round: dict[int, int] = {}
|
|
403
|
-
for each_round_number, each_fix in fix_by_round.items():
|
|
404
|
-
if not each_fix.pushed:
|
|
405
|
-
tests_added_by_round[each_round_number] = 0
|
|
406
|
-
continue
|
|
407
|
-
tests_added_by_round[each_round_number] = _count_tests_added(
|
|
408
|
-
each_fix.base_sha,
|
|
409
|
-
each_fix.new_sha,
|
|
410
|
-
repo_path,
|
|
411
|
-
)
|
|
412
|
-
|
|
413
365
|
return RunData(
|
|
414
366
|
generated_date=generated_date,
|
|
415
367
|
total_finding_count=len(distinct_findings),
|
|
416
|
-
critical_finding_count=len(all_critical_findings),
|
|
417
|
-
minor_finding_count=len(all_minor_findings),
|
|
418
368
|
fix_commit_count=fix_commit_count,
|
|
419
|
-
|
|
420
|
-
finding_count_by_round=finding_count_by_round,
|
|
421
|
-
finding_count_by_theme=finding_count_by_theme,
|
|
422
|
-
all_critical_findings=all_critical_findings,
|
|
423
|
-
all_minor_findings=all_minor_findings,
|
|
369
|
+
all_distinct_findings=distinct_findings,
|
|
424
370
|
fix_by_round=fix_by_round,
|
|
371
|
+
convergence_summary=convergence_summary,
|
|
425
372
|
)
|
|
426
373
|
|
|
427
374
|
|
|
428
|
-
def
|
|
429
|
-
"""Return
|
|
375
|
+
def _is_summary_structurally_valid(convergence_summary: object) -> bool:
|
|
376
|
+
"""Return whether the summary carries a verdict string and an issue-class list.
|
|
430
377
|
|
|
431
378
|
Args:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
max_value: The maximum value across all bars in this chart.
|
|
435
|
-
color: The CSS hex color for the bar fill.
|
|
379
|
+
convergence_summary: The parsed convergence summary, which may be None or
|
|
380
|
+
any non-dict JSON value an agent emits in place of the expected object.
|
|
436
381
|
|
|
437
382
|
Returns:
|
|
438
|
-
|
|
383
|
+
True when the summary is a dict whose verdictLine is a str and whose
|
|
384
|
+
issueClasses is a list, else False.
|
|
439
385
|
"""
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
f'<div class="bar-track">'
|
|
446
|
-
f'<div class="bar-fill" style="width:{fill_width}%;background:{color};"></div>'
|
|
447
|
-
f"</div>"
|
|
448
|
-
f'<span class="bar-value">{bar_value}</span>'
|
|
449
|
-
f"</div>"
|
|
450
|
-
)
|
|
386
|
+
if not isinstance(convergence_summary, dict):
|
|
387
|
+
return False
|
|
388
|
+
verdict_line = convergence_summary.get(SUMMARY_FIELD_VERDICT_LINE)
|
|
389
|
+
issue_classes = convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES)
|
|
390
|
+
return isinstance(verdict_line, str) and isinstance(issue_classes, list)
|
|
451
391
|
|
|
452
392
|
|
|
453
|
-
def
|
|
454
|
-
"""Return
|
|
393
|
+
def _coerce_count(raw_count: object) -> int:
|
|
394
|
+
"""Return a non-negative integer count from an LLM-authored field, else zero.
|
|
455
395
|
|
|
456
396
|
Args:
|
|
457
|
-
|
|
458
|
-
|
|
397
|
+
raw_count: The count value as read from the convergence summary, which an
|
|
398
|
+
agent may emit as null, a non-numeric string, or a valid number.
|
|
459
399
|
|
|
460
400
|
Returns:
|
|
461
|
-
|
|
401
|
+
The value parsed to an int, or 0 when it is null, non-numeric, or absent.
|
|
462
402
|
"""
|
|
463
|
-
|
|
403
|
+
if isinstance(raw_count, bool):
|
|
404
|
+
return int(raw_count)
|
|
405
|
+
if isinstance(raw_count, int):
|
|
406
|
+
return raw_count
|
|
407
|
+
if isinstance(raw_count, (float, str)):
|
|
408
|
+
try:
|
|
409
|
+
return int(float(raw_count))
|
|
410
|
+
except (TypeError, ValueError):
|
|
411
|
+
return 0
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _pluralize(count: int, singular: str, plural: str) -> str:
|
|
416
|
+
"""Return a 'count word' phrase choosing the singular or plural noun.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
count: The quantity that decides the noun form.
|
|
420
|
+
singular: The noun to use when count is exactly one.
|
|
421
|
+
plural: The noun to use for every other count.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
A phrase like '1 fix commit' or '2 fix commits'.
|
|
425
|
+
"""
|
|
426
|
+
noun = singular if count == 1 else plural
|
|
427
|
+
return f"{count} {noun}"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _render_verdict_banner(
|
|
431
|
+
convergence_summary: dict, run_data: RunData, final_sha_short: str
|
|
432
|
+
) -> str:
|
|
433
|
+
"""Return the verdict banner with the verdict line and a Python-computed sub-line.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
convergence_summary: A structurally valid convergence summary.
|
|
437
|
+
run_data: Aggregated metrics from the journal.
|
|
438
|
+
final_sha_short: First eight characters of the final commit sha.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
An HTML .verdict banner string with a check circle, vtext, and vsub.
|
|
442
|
+
"""
|
|
443
|
+
verdict_line = str(convergence_summary.get(SUMMARY_FIELD_VERDICT_LINE, ""))
|
|
444
|
+
fix_commit_phrase = _pluralize(
|
|
445
|
+
run_data.fix_commit_count, "fix commit", "fix commits"
|
|
446
|
+
)
|
|
447
|
+
vsub = f"{fix_commit_phrase} · final commit {html.escape(final_sha_short)}"
|
|
464
448
|
return (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
f"</div>
|
|
449
|
+
'<div class="verdict">'
|
|
450
|
+
'<div class="check">✓</div>'
|
|
451
|
+
"<div>"
|
|
452
|
+
f'<div class="vtext">{html.escape(verdict_line)}</div>'
|
|
453
|
+
f'<div class="vsub">{vsub}</div>'
|
|
454
|
+
"</div>"
|
|
455
|
+
"</div>"
|
|
469
456
|
)
|
|
470
457
|
|
|
471
458
|
|
|
472
|
-
def
|
|
473
|
-
"""Return
|
|
459
|
+
def _render_scene_row(scene: dict, is_problem: bool) -> str:
|
|
460
|
+
"""Return one .scene row plus its caption for a problem-or-fix scene.
|
|
474
461
|
|
|
475
462
|
Args:
|
|
476
|
-
|
|
477
|
-
|
|
463
|
+
scene: A scene dict with trigger, condition, result, and caption.
|
|
464
|
+
is_problem: True for a problem scene (bad result), False for a fix scene.
|
|
478
465
|
|
|
479
466
|
Returns:
|
|
480
|
-
An HTML .
|
|
467
|
+
An HTML .scene row followed by a .scene-cap caption line.
|
|
481
468
|
"""
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
SEVERITY_MINOR_BUCKET, minor_count, max_count, BAR_COLOR_SEVERITY_MINOR
|
|
487
|
-
)
|
|
488
|
-
return _render_chart_card("Findings by severity", rows)
|
|
469
|
+
trigger = html.escape(str(scene.get(SCENE_FIELD_TRIGGER, "")))
|
|
470
|
+
condition = str(scene.get(SCENE_FIELD_CONDITION, "")).strip()
|
|
471
|
+
result_text = html.escape(str(scene.get(SCENE_FIELD_RESULT, "")))
|
|
472
|
+
caption = html.escape(str(scene.get(SCENE_FIELD_CAPTION, "")))
|
|
489
473
|
|
|
474
|
+
result_class = "res-bad" if is_problem else "res-good"
|
|
475
|
+
result_mark = "✗" if is_problem else "✓"
|
|
476
|
+
|
|
477
|
+
parts = [f'<span class="chip">{trigger}</span>']
|
|
478
|
+
if condition:
|
|
479
|
+
parts.append('<span class="arrow">→</span>')
|
|
480
|
+
parts.append(f'<span class="note">{html.escape(condition)}</span>')
|
|
481
|
+
parts.append('<span class="arrow">→</span>')
|
|
482
|
+
parts.append(f'<span class="{result_class}">{result_mark} {result_text}</span>')
|
|
483
|
+
|
|
484
|
+
scene_row = f'<div class="scene">{"".join(parts)}</div>'
|
|
485
|
+
caption_row = f'<div class="scene-cap">{caption}</div>'
|
|
486
|
+
return scene_row + caption_row
|
|
490
487
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
"""Return a per-round finding count chart card.
|
|
488
|
+
|
|
489
|
+
def _render_pf_card(convergence_summary: dict, is_problem: bool) -> str:
|
|
490
|
+
"""Return a single problem-or-fix card drawing its scenes or a fallback caption.
|
|
495
491
|
|
|
496
492
|
Args:
|
|
497
|
-
|
|
498
|
-
|
|
493
|
+
convergence_summary: A structurally valid convergence summary.
|
|
494
|
+
is_problem: True for the problem card, False for the fix card.
|
|
499
495
|
|
|
500
496
|
Returns:
|
|
501
|
-
An HTML .
|
|
497
|
+
An HTML .pf card string with scene rows or a single fallback caption.
|
|
502
498
|
"""
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
499
|
+
if is_problem:
|
|
500
|
+
card_class = "problem"
|
|
501
|
+
tag_label = "Problem"
|
|
502
|
+
scenes_field = SUMMARY_FIELD_PROBLEM_SCENES
|
|
503
|
+
fallback_field = SUMMARY_FIELD_PR_PROBLEM
|
|
504
|
+
else:
|
|
505
|
+
card_class = "fix"
|
|
506
|
+
tag_label = "Fix"
|
|
507
|
+
scenes_field = SUMMARY_FIELD_FIX_SCENES
|
|
508
|
+
fallback_field = SUMMARY_FIELD_PR_FIX
|
|
509
|
+
|
|
510
|
+
scenes = [
|
|
511
|
+
each_scene
|
|
512
|
+
for each_scene in convergence_summary.get(scenes_field, [])
|
|
513
|
+
if isinstance(each_scene, dict)
|
|
514
|
+
]
|
|
515
|
+
if scenes:
|
|
516
|
+
body = "".join(
|
|
517
|
+
_render_scene_row(each_scene, is_problem) for each_scene in scenes
|
|
508
518
|
)
|
|
509
|
-
|
|
519
|
+
else:
|
|
520
|
+
fallback_text = html.escape(str(convergence_summary.get(fallback_field, "")))
|
|
521
|
+
body = f'<div class="scene-cap">{fallback_text}</div>'
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
f'<div class="pf {card_class}">'
|
|
525
|
+
f'<span class="pf-tag">{tag_label}</span>'
|
|
526
|
+
f"{body}"
|
|
527
|
+
"</div>"
|
|
510
528
|
)
|
|
511
|
-
return _render_chart_card("Findings by round", rows)
|
|
512
529
|
|
|
513
530
|
|
|
514
|
-
def
|
|
515
|
-
"""Return a
|
|
531
|
+
def _render_pf_grid(convergence_summary: dict) -> str:
|
|
532
|
+
"""Return the 'What this PR does' grid with a problem card and a fix card.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
convergence_summary: A structurally valid convergence summary.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
An HTML .pf-grid string with both cards.
|
|
539
|
+
"""
|
|
540
|
+
problem_card = _render_pf_card(convergence_summary, is_problem=True)
|
|
541
|
+
fix_card = _render_pf_card(convergence_summary, is_problem=False)
|
|
542
|
+
return f'<div class="pf-grid">{problem_card}{fix_card}</div>'
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _render_panel_body(lines: list[str], medium: str) -> str:
|
|
546
|
+
"""Return the inner HTML for a before-or-after panel body.
|
|
516
547
|
|
|
517
548
|
Args:
|
|
518
|
-
|
|
519
|
-
|
|
549
|
+
lines: The literal short lines to show, joined with line breaks.
|
|
550
|
+
medium: One of 'terminal', 'code', or 'text'.
|
|
520
551
|
|
|
521
552
|
Returns:
|
|
522
|
-
An HTML
|
|
553
|
+
An HTML panel-body string escaped and joined with <br>.
|
|
523
554
|
"""
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
555
|
+
escaped_lines = [html.escape(str(each_line)) for each_line in lines]
|
|
556
|
+
joined = "<br>".join(escaped_lines)
|
|
557
|
+
if medium == MEDIUM_TERMINAL:
|
|
558
|
+
return (
|
|
559
|
+
'<div class="terminal">'
|
|
560
|
+
'<div class="term-bar"><i class="r"></i><i class="y"></i><i class="g"></i>'
|
|
561
|
+
f"<span>{TIMELINE_TERMINAL_BAR_LABEL}</span></div>"
|
|
562
|
+
f'<div class="term-body">{joined}</div>'
|
|
563
|
+
"</div>"
|
|
529
564
|
)
|
|
530
|
-
|
|
565
|
+
panel_class = "code-panel" if medium == MEDIUM_CODE else "text-panel"
|
|
566
|
+
return f'<div class="{panel_class}">{joined}</div>'
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _render_term_grid(issue_class: dict, medium: str) -> str:
|
|
570
|
+
"""Return the before/after panel grid for one issue class.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
issue_class: One issue-class dict from the summary.
|
|
574
|
+
medium: The medium that styles both panels.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
An HTML .term-grid string with a before panel and an after panel.
|
|
578
|
+
"""
|
|
579
|
+
before_lines = list(issue_class.get(ISSUE_CLASS_FIELD_BEFORE_LINES, []))
|
|
580
|
+
after_lines = list(issue_class.get(ISSUE_CLASS_FIELD_AFTER_LINES, []))
|
|
581
|
+
before_body = _render_panel_body(before_lines, medium)
|
|
582
|
+
after_body = _render_panel_body(after_lines, medium)
|
|
583
|
+
return (
|
|
584
|
+
'<div class="term-grid">'
|
|
585
|
+
'<div class="term-wrap before">'
|
|
586
|
+
f'<div class="tlabel">{TIMELINE_BEFORE_LABEL} '
|
|
587
|
+
f'<span class="pill-x">{TIMELINE_BEFORE_PILL}</span></div>'
|
|
588
|
+
f"{before_body}"
|
|
589
|
+
"</div>"
|
|
590
|
+
'<div class="term-wrap after">'
|
|
591
|
+
f'<div class="tlabel">{TIMELINE_AFTER_LABEL} '
|
|
592
|
+
f'<span class="pill-c">{TIMELINE_AFTER_PILL}</span></div>'
|
|
593
|
+
f"{after_body}"
|
|
594
|
+
"</div>"
|
|
595
|
+
"</div>"
|
|
531
596
|
)
|
|
532
|
-
return _render_chart_card("Tests added per round", rows)
|
|
533
597
|
|
|
534
598
|
|
|
535
|
-
def
|
|
536
|
-
"""Return a
|
|
599
|
+
def _render_cause_line(issue_class: dict) -> str:
|
|
600
|
+
"""Return the cause line plus a muted severity/category/count/status parenthetical.
|
|
537
601
|
|
|
538
602
|
Args:
|
|
539
|
-
|
|
603
|
+
issue_class: One issue-class dict from the summary.
|
|
540
604
|
|
|
541
605
|
Returns:
|
|
542
|
-
An HTML .
|
|
606
|
+
An HTML .cause string.
|
|
543
607
|
"""
|
|
544
|
-
|
|
545
|
-
|
|
608
|
+
cause = html.escape(str(issue_class.get(ISSUE_CLASS_FIELD_CAUSE, "")))
|
|
609
|
+
severity = str(issue_class.get(ISSUE_CLASS_FIELD_SEVERITY, DEFAULT_FINDING_SEVERITY))
|
|
610
|
+
category = str(issue_class.get(ISSUE_CLASS_FIELD_CATEGORY, CATEGORY_BUG))
|
|
611
|
+
count = _coerce_count(issue_class.get(ISSUE_CLASS_FIELD_COUNT, 0))
|
|
612
|
+
status = str(issue_class.get(ISSUE_CLASS_FIELD_STATUS, ""))
|
|
613
|
+
|
|
614
|
+
category_label = CATEGORY_LABEL_BY_VALUE.get(category, category)
|
|
615
|
+
status_label = STATUS_LABEL_BY_VALUE.get(status, status)
|
|
616
|
+
parenthetical = (
|
|
617
|
+
f"({html.escape(severity)} {html.escape(category_label)} "
|
|
618
|
+
f"· ×{count} · {html.escape(status_label)})"
|
|
546
619
|
)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
620
|
+
return (
|
|
621
|
+
'<div class="cause">'
|
|
622
|
+
f"<b>Why it happened:</b> {cause} "
|
|
623
|
+
f'<span style="{CAUSE_MUTED_STYLE}">{parenthetical}</span>'
|
|
624
|
+
"</div>"
|
|
551
625
|
)
|
|
552
|
-
return _render_chart_card("Findings by theme", rows)
|
|
553
626
|
|
|
554
627
|
|
|
555
|
-
def
|
|
556
|
-
"""Return
|
|
628
|
+
def _issue_class_sort_key(issue_class: dict) -> tuple[int, int]:
|
|
629
|
+
"""Return a sort key ordering bug classes first, then by severity.
|
|
557
630
|
|
|
558
631
|
Args:
|
|
559
|
-
|
|
560
|
-
fix_by_round: Mapping of round number to fix record.
|
|
632
|
+
issue_class: One issue-class dict from the summary.
|
|
561
633
|
|
|
562
634
|
Returns:
|
|
563
|
-
|
|
635
|
+
A tuple of (category rank, severity rank); lower sorts first.
|
|
564
636
|
"""
|
|
565
|
-
|
|
566
|
-
|
|
637
|
+
category = issue_class.get(ISSUE_CLASS_FIELD_CATEGORY, CATEGORY_BUG)
|
|
638
|
+
severity = issue_class.get(ISSUE_CLASS_FIELD_SEVERITY, DEFAULT_FINDING_SEVERITY)
|
|
639
|
+
category_rank = CATEGORY_SORT_ORDER.get(category, len(CATEGORY_SORT_ORDER))
|
|
640
|
+
severity_rank = SEVERITY_SORT_RANK.get(severity, len(SEVERITY_SORT_RANK))
|
|
641
|
+
return (category_rank, severity_rank)
|
|
567
642
|
|
|
568
|
-
if fix_record is None:
|
|
569
|
-
return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
|
|
570
643
|
|
|
571
|
-
|
|
644
|
+
def _render_issue_class_heading(issue_class: dict) -> str:
|
|
645
|
+
"""Return the per-class heading with the plain bug name and an occurrence count.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
issue_class: One issue-class dict from the summary.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
An HTML .bug-head block with the plain bug name and a finding-count chip.
|
|
652
|
+
"""
|
|
653
|
+
plain_name = html.escape(str(issue_class.get(ISSUE_CLASS_FIELD_PLAINNAME, "")))
|
|
654
|
+
count = _coerce_count(issue_class.get(ISSUE_CLASS_FIELD_COUNT, 0))
|
|
655
|
+
count_phrase = html.escape(_pluralize(count, "finding", "findings"))
|
|
656
|
+
return (
|
|
657
|
+
'<div class="bug-head">'
|
|
658
|
+
f'<span class="bug-name">{plain_name}</span>'
|
|
659
|
+
f'<span class="bug-count">{count_phrase}</span>'
|
|
660
|
+
"</div>"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _render_issue_class_block(issue_class: dict) -> str:
|
|
665
|
+
"""Return one issue-class block: a name heading, before/after panels, a cause line.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
issue_class: One issue-class dict from the summary.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
An HTML fragment that opens with the .bug-head name heading, then a
|
|
672
|
+
.term-grid when panel lines exist, then the .cause line; the heading and
|
|
673
|
+
cause line alone when both before and after lines are empty.
|
|
674
|
+
"""
|
|
675
|
+
heading = _render_issue_class_heading(issue_class)
|
|
676
|
+
medium = str(issue_class.get(ISSUE_CLASS_FIELD_MEDIUM, MEDIUM_TERMINAL))
|
|
677
|
+
before_lines = list(issue_class.get(ISSUE_CLASS_FIELD_BEFORE_LINES, []))
|
|
678
|
+
after_lines = list(issue_class.get(ISSUE_CLASS_FIELD_AFTER_LINES, []))
|
|
679
|
+
|
|
680
|
+
cause_line = _render_cause_line(issue_class)
|
|
681
|
+
if not before_lines and not after_lines:
|
|
682
|
+
return heading + cause_line
|
|
683
|
+
|
|
684
|
+
term_grid = _render_term_grid(issue_class, medium)
|
|
685
|
+
return heading + term_grid + cause_line
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _render_issue_class_panels(convergence_summary: dict) -> str:
|
|
689
|
+
"""Return the per-issue-class before/after panels and cause lines.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
convergence_summary: A structurally valid convergence summary.
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
An HTML fragment with one block per issue class, ordered bug classes
|
|
696
|
+
first then by severity.
|
|
697
|
+
"""
|
|
698
|
+
issue_classes = [
|
|
699
|
+
each_class
|
|
700
|
+
for each_class in convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES, [])
|
|
701
|
+
if isinstance(each_class, dict)
|
|
702
|
+
]
|
|
703
|
+
if not issue_classes:
|
|
572
704
|
return (
|
|
573
|
-
|
|
574
|
-
|
|
705
|
+
'<p class="subtitle">No issues were caught — '
|
|
706
|
+
"every review lens was clean.</p>"
|
|
575
707
|
)
|
|
708
|
+
sorted_classes = sorted(issue_classes, key=_issue_class_sort_key)
|
|
709
|
+
return "".join(
|
|
710
|
+
_render_issue_class_block(each_class) for each_class in sorted_classes
|
|
711
|
+
)
|
|
576
712
|
|
|
577
|
-
if not fix_record.new_sha:
|
|
578
|
-
return '<div class="bug-fix"><b>Fix:</b> resolved during convergence.</div>'
|
|
579
713
|
|
|
580
|
-
|
|
714
|
+
def _render_caught_lead(
|
|
715
|
+
convergence_summary: dict, run_data: RunData, round_count: int
|
|
716
|
+
) -> str:
|
|
717
|
+
"""Return the run-stats lead line that opens the caught section.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
convergence_summary: A structurally valid convergence summary.
|
|
721
|
+
run_data: Aggregated metrics from the journal.
|
|
722
|
+
round_count: Total number of convergence rounds.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
An HTML .subtitle line stating bug-class, finding, round, and fix counts.
|
|
726
|
+
"""
|
|
727
|
+
issue_classes = [
|
|
728
|
+
each_class
|
|
729
|
+
for each_class in convergence_summary.get(SUMMARY_FIELD_ISSUE_CLASSES, [])
|
|
730
|
+
if isinstance(each_class, dict)
|
|
731
|
+
]
|
|
732
|
+
class_phrase = _pluralize(len(issue_classes), "bug class", "bug classes")
|
|
733
|
+
finding_phrase = _pluralize(run_data.total_finding_count, "finding", "findings")
|
|
734
|
+
round_phrase = _pluralize(round_count, "round", "rounds")
|
|
735
|
+
fix_phrase = _pluralize(run_data.fix_commit_count, "fix commit", "fix commits")
|
|
581
736
|
return (
|
|
582
|
-
f'<
|
|
583
|
-
f"
|
|
737
|
+
f'<p class="subtitle">{class_phrase} ({finding_phrase} in all), '
|
|
738
|
+
f"caught and fixed across {round_phrase} in {fix_phrase}.</p>"
|
|
584
739
|
)
|
|
585
740
|
|
|
586
741
|
|
|
587
|
-
def
|
|
588
|
-
|
|
589
|
-
finding: RawFinding,
|
|
590
|
-
fix_by_round: dict[int, FixRecord],
|
|
591
|
-
card_class: str,
|
|
592
|
-
) -> str:
|
|
593
|
-
"""Return an HTML .bug-card element for one finding.
|
|
742
|
+
def _appendix_finding_sort_key(finding: RawFinding) -> tuple[int, int, str, int]:
|
|
743
|
+
"""Return a sort key grouping appendix findings by category then severity.
|
|
594
744
|
|
|
595
745
|
Args:
|
|
596
|
-
|
|
597
|
-
finding: The raw finding to render.
|
|
598
|
-
fix_by_round: Mapping of round number to fix record for the fix sub-block.
|
|
599
|
-
card_class: Either 'crit' or 'minor'.
|
|
746
|
+
finding: One distinct finding.
|
|
600
747
|
|
|
601
748
|
Returns:
|
|
602
|
-
|
|
749
|
+
A tuple of (category rank, severity rank, file, line).
|
|
603
750
|
"""
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
escaped_detail = html.escape(finding.detail)
|
|
608
|
-
escaped_file = html.escape(finding.file)
|
|
609
|
-
line_number = finding.line
|
|
610
|
-
round_number = finding.round_number
|
|
751
|
+
category_rank = CATEGORY_SORT_ORDER.get(finding.category, len(CATEGORY_SORT_ORDER))
|
|
752
|
+
severity_rank = SEVERITY_SORT_RANK.get(finding.severity, len(SEVERITY_SORT_RANK))
|
|
753
|
+
return (category_rank, severity_rank, finding.file, finding.line)
|
|
611
754
|
|
|
612
|
-
fix_block = _render_fix_block(finding, fix_by_round)
|
|
613
755
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
756
|
+
def _render_appendix(findings: list[RawFinding]) -> str:
|
|
757
|
+
"""Return a collapsed <details> appendix of raw distinct findings.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
findings: The distinct findings to list, grouped by category then severity.
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
An HTML <details> string listing each finding as 'file:line — P# — title'.
|
|
764
|
+
"""
|
|
765
|
+
if not findings:
|
|
766
|
+
return ""
|
|
767
|
+
sorted_findings = sorted(findings, key=_appendix_finding_sort_key)
|
|
768
|
+
items = "".join(
|
|
769
|
+
f'<div class="appendix-item">'
|
|
770
|
+
f"{html.escape(each_finding.file)}:{each_finding.line} — "
|
|
771
|
+
f"{html.escape(each_finding.severity)} — "
|
|
772
|
+
f"{html.escape(each_finding.title)}"
|
|
627
773
|
f"</div>"
|
|
774
|
+
for each_finding in sorted_findings
|
|
775
|
+
)
|
|
776
|
+
return (
|
|
777
|
+
'<details class="appendix">'
|
|
778
|
+
f"<summary>Raw findings ({len(findings)})</summary>"
|
|
779
|
+
f'<div class="appendix-body">{items}</div>'
|
|
780
|
+
"</details>"
|
|
628
781
|
)
|
|
629
782
|
|
|
630
783
|
|
|
631
|
-
def
|
|
632
|
-
|
|
784
|
+
def _render_summary_body(
|
|
785
|
+
run_data: RunData, round_count: int, final_sha_short: str
|
|
786
|
+
) -> str:
|
|
787
|
+
"""Return the body for a run that carries a valid convergence summary.
|
|
633
788
|
|
|
634
789
|
Args:
|
|
635
|
-
|
|
636
|
-
|
|
790
|
+
run_data: Aggregated metrics from the journal.
|
|
791
|
+
round_count: Total number of convergence rounds.
|
|
792
|
+
final_sha_short: First eight characters of the final commit sha.
|
|
637
793
|
|
|
638
794
|
Returns:
|
|
639
|
-
An HTML
|
|
795
|
+
An HTML body fragment: verdict banner, problem/fix cards, then a single
|
|
796
|
+
caught section that opens with a run-stats lead line and holds the
|
|
797
|
+
issue-class before/after panels, followed by the collapsed appendix.
|
|
640
798
|
"""
|
|
641
|
-
|
|
799
|
+
convergence_summary = run_data.convergence_summary
|
|
800
|
+
if convergence_summary is None:
|
|
801
|
+
return _render_degraded_body(run_data, round_count, final_sha_short)
|
|
802
|
+
|
|
803
|
+
verdict_banner = _render_verdict_banner(
|
|
804
|
+
convergence_summary, run_data, final_sha_short
|
|
805
|
+
)
|
|
806
|
+
pf_grid = _render_pf_grid(convergence_summary)
|
|
807
|
+
caught_lead = _render_caught_lead(convergence_summary, run_data, round_count)
|
|
808
|
+
issue_panels = _render_issue_class_panels(convergence_summary)
|
|
809
|
+
appendix = _render_appendix(run_data.all_distinct_findings)
|
|
642
810
|
return (
|
|
643
|
-
f
|
|
644
|
-
f
|
|
645
|
-
f
|
|
646
|
-
f"
|
|
811
|
+
f"{verdict_banner}"
|
|
812
|
+
f"<h2>What this PR does</h2>{pf_grid}"
|
|
813
|
+
f"<h2>What was caught — and how it looked</h2>{caught_lead}{issue_panels}"
|
|
814
|
+
f"{appendix}"
|
|
647
815
|
)
|
|
648
816
|
|
|
649
817
|
|
|
650
|
-
def
|
|
651
|
-
|
|
652
|
-
fix_by_round: dict[int, FixRecord],
|
|
653
|
-
card_class: str,
|
|
818
|
+
def _render_degraded_body(
|
|
819
|
+
run_data: RunData, round_count: int, final_sha_short: str
|
|
654
820
|
) -> str:
|
|
655
|
-
"""Return
|
|
821
|
+
"""Return the minimal degraded body for a run with no valid summary.
|
|
656
822
|
|
|
657
823
|
Args:
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
824
|
+
run_data: Aggregated metrics from the journal.
|
|
825
|
+
round_count: Total number of convergence rounds.
|
|
826
|
+
final_sha_short: First eight characters of the final commit sha.
|
|
661
827
|
|
|
662
828
|
Returns:
|
|
663
|
-
An HTML
|
|
829
|
+
An HTML body fragment: a plain run-stats note and the collapsed
|
|
830
|
+
raw-findings appendix, with no scene, table, rollup, or timeline markup.
|
|
664
831
|
"""
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
cards = "".join(
|
|
668
|
-
_render_bug_card(each_index + 1, each_finding, fix_by_round, card_class)
|
|
669
|
-
for each_index, each_finding in enumerate(findings)
|
|
832
|
+
fix_commit_phrase = _pluralize(
|
|
833
|
+
run_data.fix_commit_count, "fix commit", "fix commits"
|
|
670
834
|
)
|
|
671
|
-
|
|
835
|
+
note = (
|
|
836
|
+
'<p class="subtitle">'
|
|
837
|
+
f"{run_data.total_finding_count} distinct findings across {round_count} "
|
|
838
|
+
f"rounds, resolved in {fix_commit_phrase}. "
|
|
839
|
+
f"Final commit <code>{html.escape(final_sha_short)}</code>."
|
|
840
|
+
"</p>"
|
|
841
|
+
)
|
|
842
|
+
appendix = _render_appendix(run_data.all_distinct_findings)
|
|
843
|
+
return f"{note}{appendix}"
|
|
672
844
|
|
|
673
845
|
|
|
674
846
|
def render_report_html(
|
|
675
847
|
run_data: RunData, pr_metadata: PrMetadata, generated_date: str
|
|
676
848
|
) -> str:
|
|
677
|
-
"""Render the convergence
|
|
849
|
+
"""Render the convergence summary report as an HTML string.
|
|
678
850
|
|
|
679
851
|
Args:
|
|
680
852
|
run_data: Aggregated metrics from the workflow journal and transcripts.
|
|
@@ -684,129 +856,43 @@ def render_report_html(
|
|
|
684
856
|
Returns:
|
|
685
857
|
A complete HTML document string.
|
|
686
858
|
"""
|
|
859
|
+
short_sha_length = SHORT_SHA_LENGTH
|
|
687
860
|
pr_number = pr_metadata.number
|
|
688
861
|
owner = html.escape(pr_metadata.owner)
|
|
689
862
|
repo = html.escape(pr_metadata.repo)
|
|
690
|
-
final_sha_short = pr_metadata.final_sha[:
|
|
863
|
+
final_sha_short = pr_metadata.final_sha[:short_sha_length]
|
|
691
864
|
round_count = pr_metadata.round_count
|
|
692
865
|
|
|
693
|
-
total_findings = run_data.total_finding_count
|
|
694
|
-
critical_count = run_data.critical_finding_count
|
|
695
|
-
minor_count = run_data.minor_finding_count
|
|
696
|
-
fix_commit_count = run_data.fix_commit_count
|
|
697
|
-
tests_added_total = sum(run_data.tests_added_by_round.values())
|
|
698
|
-
|
|
699
866
|
head_html = HTML_HEAD_TEMPLATE.format(
|
|
700
867
|
pr_number=pr_number,
|
|
701
868
|
style_block=HTML_STYLE_BLOCK,
|
|
702
869
|
)
|
|
703
870
|
|
|
704
871
|
subtitle = (
|
|
705
|
-
f'<p class="subtitle">{owner}/{repo}
|
|
706
|
-
f"
|
|
872
|
+
f'<p class="subtitle">{owner}/{repo} · '
|
|
873
|
+
f"{run_data.total_finding_count} findings over {round_count} rounds "
|
|
874
|
+
f"· {html.escape(generated_date)}</p>"
|
|
707
875
|
)
|
|
708
876
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
)
|
|
714
|
-
glance_resolution = (
|
|
715
|
-
f'<div class="glance-section"><strong>Resolution:</strong> every finding was fixed '
|
|
716
|
-
f"before the PR was marked ready; {fix_commit_count} fix commits landed.</div>"
|
|
717
|
-
)
|
|
718
|
-
glance_status = (
|
|
719
|
-
f'<div class="glance-section"><strong>Status:</strong> the run converged on commit '
|
|
720
|
-
f"{html.escape(final_sha_short)}.</div>"
|
|
721
|
-
)
|
|
722
|
-
at_a_glance = (
|
|
723
|
-
f'<div class="at-a-glance">'
|
|
724
|
-
f'<div class="glance-title">At a Glance</div>'
|
|
725
|
-
f'<div class="glance-sections">'
|
|
726
|
-
f"{glance_caught}{glance_resolution}{glance_status}"
|
|
727
|
-
f"</div></div>"
|
|
728
|
-
)
|
|
729
|
-
|
|
730
|
-
nav_toc = (
|
|
731
|
-
'<nav class="nav-toc">'
|
|
732
|
-
'<a href="#numbers">The numbers</a>'
|
|
733
|
-
'<a href="#critical">Critical findings</a>'
|
|
734
|
-
'<a href="#minor">Minor findings</a>'
|
|
735
|
-
'<a href="#status">Status</a>'
|
|
736
|
-
"</nav>"
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
stats_row = (
|
|
740
|
-
'<div class="stats-row">'
|
|
741
|
-
+ _render_stat("Findings", total_findings)
|
|
742
|
-
+ _render_stat("Critical", critical_count)
|
|
743
|
-
+ _render_stat("Minor", minor_count)
|
|
744
|
-
+ _render_stat("Rounds", round_count)
|
|
745
|
-
+ _render_stat("Fix commits", fix_commit_count)
|
|
746
|
-
+ _render_stat("Tests added", tests_added_total)
|
|
747
|
-
+ "</div>"
|
|
748
|
-
)
|
|
749
|
-
|
|
750
|
-
severity_chart = _render_severity_chart(critical_count, minor_count)
|
|
751
|
-
round_chart = _render_round_findings_chart(
|
|
752
|
-
round_count, run_data.finding_count_by_round
|
|
753
|
-
)
|
|
754
|
-
tests_chart = _render_tests_chart(round_count, run_data.tests_added_by_round)
|
|
755
|
-
theme_chart = _render_theme_chart(run_data.finding_count_by_theme)
|
|
756
|
-
|
|
757
|
-
charts_row_one = f'<div class="charts-row">{severity_chart}{round_chart}</div>'
|
|
758
|
-
charts_row_two = f'<div class="charts-row">{tests_chart}{theme_chart}</div>'
|
|
759
|
-
|
|
760
|
-
numbers_section = (
|
|
761
|
-
f'<h2 id="numbers">The numbers</h2>{charts_row_one}{charts_row_two}'
|
|
762
|
-
)
|
|
877
|
+
if _is_summary_structurally_valid(run_data.convergence_summary):
|
|
878
|
+
body_main = _render_summary_body(run_data, round_count, final_sha_short)
|
|
879
|
+
else:
|
|
880
|
+
body_main = _render_degraded_body(run_data, round_count, final_sha_short)
|
|
763
881
|
|
|
764
|
-
|
|
765
|
-
run_data.
|
|
882
|
+
fix_commit_phrase = _pluralize(
|
|
883
|
+
run_data.fix_commit_count, "fix commit", "fix commits"
|
|
766
884
|
)
|
|
767
|
-
critical_intro = '<p class="section-intro">P0 and P1 findings caught and fixed during the run.</p>'
|
|
768
|
-
critical_section = f'<h2 id="critical">Critical findings</h2>' + (
|
|
769
|
-
f"{critical_intro}{critical_cards}"
|
|
770
|
-
if run_data.all_critical_findings
|
|
771
|
-
else '<p class="section-intro">No critical findings.</p>'
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
minor_intro = (
|
|
775
|
-
'<p class="section-intro">P2 findings caught and fixed during the run.</p>'
|
|
776
|
-
)
|
|
777
|
-
minor_cards = _render_finding_cards(
|
|
778
|
-
run_data.all_minor_findings, run_data.fix_by_round, "minor"
|
|
779
|
-
)
|
|
780
|
-
minor_section = f'<h2 id="minor">Minor findings</h2>{minor_intro}{minor_cards}'
|
|
781
|
-
|
|
782
|
-
horizon_tip = (
|
|
783
|
-
f'<div class="horizon-tip">Final commit <code>{html.escape(final_sha_short)}</code> '
|
|
784
|
-
f"· {round_count} rounds · {fix_commit_count} fix commits.</div>"
|
|
785
|
-
)
|
|
786
|
-
status_section = (
|
|
787
|
-
f'<h2 id="status">Status</h2>'
|
|
788
|
-
f'<div class="horizon-card">'
|
|
789
|
-
f'<div class="horizon-title">Converged</div>'
|
|
790
|
-
f'<div class="horizon-possible">The run converged and the PR was marked ready.</div>'
|
|
791
|
-
f"{horizon_tip}"
|
|
792
|
-
f"</div>"
|
|
793
|
-
)
|
|
794
|
-
|
|
795
885
|
footer = (
|
|
796
|
-
f"<footer>{owner}/{repo}
|
|
886
|
+
f"<footer>{owner}/{repo} · PR #{pr_number} · "
|
|
887
|
+
f"{run_data.total_finding_count} findings · {round_count} rounds "
|
|
888
|
+
f"· {fix_commit_phrase} · "
|
|
797
889
|
f"generated {html.escape(generated_date)} from the autoconverge run journal.</footer>"
|
|
798
890
|
)
|
|
799
891
|
|
|
800
892
|
body_content = (
|
|
801
|
-
f"<h1>PR #{pr_number} Convergence
|
|
893
|
+
f"<h1>PR #{pr_number} Convergence Summary</h1>"
|
|
802
894
|
f"{subtitle}"
|
|
803
|
-
f"{
|
|
804
|
-
f"{nav_toc}"
|
|
805
|
-
f"{stats_row}"
|
|
806
|
-
f"{numbers_section}"
|
|
807
|
-
f"{critical_section}"
|
|
808
|
-
f"{minor_section}"
|
|
809
|
-
f"{status_section}"
|
|
895
|
+
f"{body_main}"
|
|
810
896
|
f"{footer}"
|
|
811
897
|
)
|
|
812
898
|
|
|
@@ -843,7 +929,7 @@ def _parse_pr_arg(pr_arg: str, err_stream: TextIO) -> tuple[str, str, int] | Non
|
|
|
843
929
|
|
|
844
930
|
|
|
845
931
|
def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> int:
|
|
846
|
-
"""Parse CLI arguments, load run data, render HTML, write the
|
|
932
|
+
"""Parse CLI arguments, load run data, render HTML, write the file, emit the path.
|
|
847
933
|
|
|
848
934
|
Args:
|
|
849
935
|
out_stream: Stream to write the output file path to on success.
|
|
@@ -853,7 +939,7 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
|
|
|
853
939
|
Exit code (0 on success, 1 on argument error).
|
|
854
940
|
"""
|
|
855
941
|
argument_parser = argparse.ArgumentParser(
|
|
856
|
-
description="Render autoconverge convergence
|
|
942
|
+
description="Render autoconverge convergence summary HTML."
|
|
857
943
|
)
|
|
858
944
|
argument_parser.add_argument(
|
|
859
945
|
"--journal", required=True, help="Path to wf_<runId>.json"
|
|
@@ -865,14 +951,15 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
|
|
|
865
951
|
"--rounds", required=True, type=int, help="Total round count"
|
|
866
952
|
)
|
|
867
953
|
argument_parser.add_argument(
|
|
868
|
-
"--
|
|
954
|
+
"--summary-file",
|
|
955
|
+
default=None,
|
|
956
|
+
help="Path to a JSON file with the convergence summary to inject",
|
|
869
957
|
)
|
|
870
958
|
|
|
871
959
|
parsed_args = argument_parser.parse_args()
|
|
872
960
|
|
|
873
961
|
journal_path = Path(parsed_args.journal).resolve()
|
|
874
962
|
out_path = Path(parsed_args.out)
|
|
875
|
-
repo_path = Path(parsed_args.repo).resolve()
|
|
876
963
|
|
|
877
964
|
parsed_pr = _parse_pr_arg(parsed_args.pr, err_stream)
|
|
878
965
|
if parsed_pr is None:
|
|
@@ -890,7 +977,11 @@ def main(out_stream: TextIO = sys.stdout, err_stream: TextIO = sys.stderr) -> in
|
|
|
890
977
|
round_count=parsed_args.rounds,
|
|
891
978
|
)
|
|
892
979
|
|
|
893
|
-
run_data = load_run_data(journal_path
|
|
980
|
+
run_data = load_run_data(journal_path)
|
|
981
|
+
if parsed_args.summary_file:
|
|
982
|
+
run_data.convergence_summary = json.loads(
|
|
983
|
+
Path(parsed_args.summary_file).read_text(encoding="utf-8")
|
|
984
|
+
)
|
|
894
985
|
html_content = render_report_html(run_data, pr_metadata, run_data.generated_date)
|
|
895
986
|
|
|
896
987
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|