claude-dev-env 1.69.1 → 1.70.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/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +600 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -0
- package/hooks/blocking/code_rules_shared.py +47 -0
- package/hooks/blocking/tdd_enforcer.py +7 -2
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +587 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +383 -0
- package/hooks/blocking/test_tdd_enforcer.py +106 -1
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +96 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +24 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +36 -5
- package/skills/autoconverge/workflow/render_report.py +43 -5
- package/skills/autoconverge/workflow/test_render_report.py +43 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Orphan File Reference in a Per-Directory CLAUDE.md
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Every bare filename a per-directory `CLAUDE.md` table names in its first column points at a file that exists in the directory subtree the `CLAUDE.md` describes. A first-column cell naming a file that exists nowhere in that subtree points a reader at something that is not there: the listing claims a file the directory does not hold.
|
|
8
|
+
|
|
9
|
+
When you add a table row, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row that named it.
|
|
10
|
+
|
|
11
|
+
## What the gate checks
|
|
12
|
+
|
|
13
|
+
The `claude_md_orphan_file_blocker.py` hook runs on every Write, Edit, and MultiEdit whose target basename is `CLAUDE.md`. It:
|
|
14
|
+
|
|
15
|
+
1. Reads the content the tool would leave on disk. For a Write that is the full `content`. For an Edit or MultiEdit it reconstructs the post-edit file — the existing on-disk file with the replacements applied — and also notes which orphans the file already held before the edit, so a pre-existing orphan on an untouched line is excluded and only an orphan the edit introduces is reported; when the existing file cannot be read, it scans the raw `new_string` fragment(s) instead.
|
|
16
|
+
2. Skips any line inside a fenced code block (between a ``` or `~~~` fence pair), since an example table there is documentation, not a live listing.
|
|
17
|
+
3. Takes the first column of each remaining markdown table row and keeps the cells that name a bare filename: wrapped in backticks, no path separator, not a slash-command, and ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`).
|
|
18
|
+
4. Blocks the write when a named file exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
|
|
19
|
+
|
|
20
|
+
The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
|
|
21
|
+
|
|
22
|
+
## Why this is a hook, not a lint pass
|
|
23
|
+
|
|
24
|
+
A table row that names an absent file reads as a contract: a reader trusts the listing to map the directory. A wrong row sends the reader looking for a file that is not there and erodes trust in every other row. Catching it as each row is written keeps the table and the directory in step.
|
package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py
CHANGED
|
@@ -92,6 +92,16 @@ TIMELINE_TERMINAL_BAR_LABEL = "terminal"
|
|
|
92
92
|
|
|
93
93
|
CAUSE_MUTED_STYLE = "color:#94a3b8;"
|
|
94
94
|
|
|
95
|
+
ISSUE_ICON_BY_CATEGORY = {
|
|
96
|
+
"bug": "\U0001f41e",
|
|
97
|
+
"code-standard": "\U0001f4cf",
|
|
98
|
+
}
|
|
99
|
+
DEFAULT_ISSUE_ICON = "\U0001f41e"
|
|
100
|
+
|
|
101
|
+
SCORECARD_LABEL_CAUGHT = "caught"
|
|
102
|
+
SCORECARD_LABEL_ROUNDS = "rounds"
|
|
103
|
+
SCORECARD_LABEL_REMAINING = "left"
|
|
104
|
+
|
|
95
105
|
GITHUB_PR_URL_TEMPLATE = "https://github.com/{owner}/{repo}/pull/{number}"
|
|
96
106
|
|
|
97
107
|
SUMMARY_PR_COORDINATES_TEMPLATE = "owner={owner} repo={repo} PR #{pr_number} ({url})"
|
|
@@ -128,12 +138,13 @@ Write so a non-programmer understands every line. The reader has never seen the
|
|
|
128
138
|
- problemScenes/fixScenes example (a DIFFERENT project, to teach the style not the answer). Problem scene: trigger "export stops at batch 90", condition "you restart it", result "starts again at batch 1", caption "A halted export threw away the 90 batches it had already finished and began again." Mirroring fix scene: trigger "export stops at batch 90", condition "you restart it", result "continues at batch 91", caption "A restarted export now picks up at the next unfinished batch."
|
|
129
139
|
- GROUP near-duplicate findings into issue CLASSES: the same KIND of problem across different files or lines becomes ONE class with a count. Example: seven "Missing return type annotation on test function" findings become one class with count 7.
|
|
130
140
|
- TRANSLATE reviewer jargon into plain everyday English. Examples: "CodingGuidelineID 1000000 / Repository guideline (Types)" -> "a typing rule the project enforces"; "missing return type annotation / Add -> None" -> "a test did not declare what it returns"; "Banned identifier result" -> "a vague variable name the project bans"; a magic-value finding -> "a raw number or string that should be a named value".
|
|
131
|
-
-
|
|
141
|
+
- TRANSLATE internal-mechanism nouns the same way. "guard", "hook", "checker", "validator", "gate", "parser", "entry-point", "AST" each name a part of the machine a non-programmer never sees. Replace each with the everyday check it performs or, better, the effect it had: "the guard blocked the save" -> "saving the file was blocked"; "the hook fired" -> "the project's automatic check ran". Never leave a bare "guard", "hook", or "checker" in a sentence a reader sees.
|
|
142
|
+
- plainName: a short plain symptom a non-programmer recognizes - name what BROKE or what a person would notice, not the internal component. Carry NO tool token, rule id, file path, line number, severity code (P0/P1/P2), bot name, or internal-mechanism noun (guard, hook, checker, validator, gate, parser, AST, annotation). Lead with the visible effect. Good: "The install command quietly did nothing"; "Saving a file was blocked by mistake". Weak: "Entry-point guard misfires under symlinked bin invocation"; "The guard wrongly flagged a field".
|
|
132
143
|
- cause is the field that matters most. Sentence 1: the observable impact - what a person saw go wrong, or would have. Sentence 2: the cause in everyday terms. At most 2 sentences, no paragraphs.
|
|
133
144
|
- In cause describe the CONSEQUENCE, never the internal mechanism. Do not narrate control flow, comparisons, or internal state. Banned phrasings: "a check returned false", "two forms of the path", "treated the run as a non-run", "the values did not match", "a guard misfired". Say what that meant for the person instead.
|
|
134
145
|
- Prefer concrete everyday words: "the install command" and "on Mac and Linux" over "the launcher", "the entry-point guard", or "the symlink". Read each sentence as if aloud to a non-programmer; if they would not follow it, rewrite it.
|
|
135
146
|
- cause worked example (a DIFFERENT problem, to show the style, not the answer). WEAK, do not write like this: "A cached value's timestamp was compared across mismatched time zones, so the freshness check evaluated false and the entry was treated as stale and re-fetched on every request." STRONG, write like this: "Every page re-downloaded the same data from scratch instead of reusing the copy it already had, so pages loaded slower than needed. The app misjudged its saved copy as out of date."
|
|
136
|
-
- medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise.
|
|
147
|
+
- medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise. PREFER 'terminal' or 'text' so the panels show what a PERSON sees; reserve 'code' for the rare finding whose fix truly is a one-line code change a reader would recognize. When in doubt, choose 'text' and describe the effect in plain words, not source code.
|
|
137
148
|
- beforeLines and afterLines are the literal short lines to show in each panel - what a person sees. For a terminal: include the prompt + command + the (missing or present) output. For code: 1 to 4 lines before, 1 to 4 lines after. Keep every line short. Never fabricate exact output text that is not implied by the finding - if the exact text is unknown, show the shape (e.g. "(no output - nothing installed)"). Leave both arrays empty to fall back to the cause line alone.
|
|
138
149
|
- Lead with category 'bug' classes, then 'code-standard'. Create one class per distinct KIND of problem, however many that is; never merge different kinds or drop classes to hit a number.
|
|
139
150
|
- status is 'fixed' unless the fix summaries or the deferred code-standard note mark the class deferred, in which case status is 'deferred'.
|
|
@@ -148,6 +159,7 @@ HTML_DOCTYPE = "<!DOCTYPE html>"
|
|
|
148
159
|
HTML_HEAD_TEMPLATE = """\
|
|
149
160
|
<head>
|
|
150
161
|
<meta charset="utf-8">
|
|
162
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
151
163
|
<title>PR #{pr_number} Convergence Summary</title>
|
|
152
164
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
153
165
|
{style_block}
|
|
@@ -156,9 +168,10 @@ HTML_HEAD_TEMPLATE = """\
|
|
|
156
168
|
HTML_STYLE_BLOCK = """\
|
|
157
169
|
<style>
|
|
158
170
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
171
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
159
172
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; color: #334155; line-height: 1.6; padding: 48px 20px; }
|
|
160
173
|
.container { max-width: 860px; margin: 0 auto; }
|
|
161
|
-
h1 { font-size: 30px; font-weight: 800; color: #0f172a; letter-spacing: -0.5px; }
|
|
174
|
+
h1 { font-size: clamp(22px, 6vw, 30px); font-weight: 800; color: #0f172a; letter-spacing: -0.5px; line-height: 1.2; }
|
|
162
175
|
.subtitle { color: #64748b; font-size: 14px; margin: 6px 0 26px; }
|
|
163
176
|
h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: #94a3b8; margin: 40px 0 16px; }
|
|
164
177
|
|
|
@@ -168,6 +181,14 @@ HTML_STYLE_BLOCK = """\
|
|
|
168
181
|
.verdict .vtext { font-size: 16px; font-weight: 700; color:#0f172a; }
|
|
169
182
|
.verdict .vsub { font-size: 13px; font-weight: 500; color:#15803d; }
|
|
170
183
|
|
|
184
|
+
/* scorecard */
|
|
185
|
+
.scorecard { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 14px; }
|
|
186
|
+
.stat { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:16px 14px; text-align:center; }
|
|
187
|
+
.stat-num { font-size: clamp(26px, 8vw, 34px); font-weight:800; color:#0f172a; line-height:1.1; }
|
|
188
|
+
.stat-label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; color:#94a3b8; margin-top:4px; }
|
|
189
|
+
.stat.good { border-color:#22c55e; background:linear-gradient(135deg,#f0fdf4,#dcfce7); }
|
|
190
|
+
.stat.good .stat-num { color:#16a34a; }
|
|
191
|
+
|
|
171
192
|
/* problem / fix scenes */
|
|
172
193
|
.pf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
173
194
|
.pf { border-radius: 12px; padding: 18px 20px; border:1px solid #e2e8f0; background:#fff; }
|
|
@@ -186,8 +207,10 @@ HTML_STYLE_BLOCK = """\
|
|
|
186
207
|
.scene-cap:last-child { margin-bottom:0; }
|
|
187
208
|
|
|
188
209
|
/* bug class */
|
|
189
|
-
.bug-head { display:flex; align-items:
|
|
210
|
+
.bug-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:32px 0 12px; }
|
|
190
211
|
.bug-head:first-of-type { margin-top:8px; }
|
|
212
|
+
.bug-title { display:flex; align-items:center; gap:8px; min-width:0; }
|
|
213
|
+
.bug-icon { font-size:18px; line-height:1; flex:0 0 auto; }
|
|
191
214
|
.bug-name { font-size:16px; font-weight:700; color:#0f172a; }
|
|
192
215
|
.bug-count { font-size:12px; font-weight:600; color:#64748b; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:20px; padding:3px 11px; white-space:nowrap; flex:0 0 auto; }
|
|
193
216
|
|
|
@@ -222,5 +245,13 @@ HTML_STYLE_BLOCK = """\
|
|
|
222
245
|
|
|
223
246
|
footer { margin-top:40px; padding-top:16px; border-top:1px solid #e2e8f0; color:#94a3b8; font-size:12px; }
|
|
224
247
|
footer code { background:#e2e8f0; padding:1px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; }
|
|
225
|
-
@media (max-width:680px){
|
|
248
|
+
@media (max-width:680px){
|
|
249
|
+
body{ padding:24px 14px; }
|
|
250
|
+
.pf-grid,.term-grid{ grid-template-columns:1fr; }
|
|
251
|
+
.scorecard{ gap:8px; }
|
|
252
|
+
.stat{ padding:12px 8px; }
|
|
253
|
+
.stat-label{ font-size:11px; }
|
|
254
|
+
.bug-head{ flex-wrap:wrap; gap:6px; }
|
|
255
|
+
h2{ margin:28px 0 12px; }
|
|
256
|
+
}
|
|
226
257
|
</style>"""
|
|
@@ -16,6 +16,7 @@ from autoconverge_report_constants.render_report_constants import (
|
|
|
16
16
|
CAUSE_MUTED_STYLE,
|
|
17
17
|
DEFAULT_FINDING_CATEGORY,
|
|
18
18
|
DEFAULT_FINDING_SEVERITY,
|
|
19
|
+
DEFAULT_ISSUE_ICON,
|
|
19
20
|
GITHUB_PR_URL_TEMPLATE,
|
|
20
21
|
HTML_DOCTYPE,
|
|
21
22
|
HTML_HEAD_TEMPLATE,
|
|
@@ -30,6 +31,7 @@ from autoconverge_report_constants.render_report_constants import (
|
|
|
30
31
|
ISSUE_CLASS_FIELD_PLAINNAME,
|
|
31
32
|
ISSUE_CLASS_FIELD_SEVERITY,
|
|
32
33
|
ISSUE_CLASS_FIELD_STATUS,
|
|
34
|
+
ISSUE_ICON_BY_CATEGORY,
|
|
33
35
|
JOURNAL_SIBLING_SUBAGENTS,
|
|
34
36
|
JOURNAL_SIBLING_WORKFLOWS,
|
|
35
37
|
LABEL_CONVERGENCE_SUMMARY,
|
|
@@ -43,6 +45,9 @@ from autoconverge_report_constants.render_report_constants import (
|
|
|
43
45
|
SCENE_FIELD_CONDITION,
|
|
44
46
|
SCENE_FIELD_RESULT,
|
|
45
47
|
SCENE_FIELD_TRIGGER,
|
|
48
|
+
SCORECARD_LABEL_CAUGHT,
|
|
49
|
+
SCORECARD_LABEL_REMAINING,
|
|
50
|
+
SCORECARD_LABEL_ROUNDS,
|
|
46
51
|
SEVERITY_SORT_RANK,
|
|
47
52
|
SHORT_SHA_LENGTH,
|
|
48
53
|
STATUS_LABEL_BY_VALUE,
|
|
@@ -456,6 +461,30 @@ def _render_verdict_banner(
|
|
|
456
461
|
)
|
|
457
462
|
|
|
458
463
|
|
|
464
|
+
def _render_scorecard(run_data: RunData, round_count: int) -> str:
|
|
465
|
+
"""Return the at-a-glance scorecard: findings caught, rounds, and zero remaining.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
run_data: Aggregated metrics from the journal.
|
|
469
|
+
round_count: Total number of convergence rounds.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
An HTML .scorecard grid of three .stat tiles; the remaining tile is marked
|
|
473
|
+
.good and reads zero, since the report renders only on a converged run.
|
|
474
|
+
"""
|
|
475
|
+
remaining_count = 0
|
|
476
|
+
return (
|
|
477
|
+
'<div class="scorecard">'
|
|
478
|
+
f'<div class="stat"><div class="stat-num">{run_data.total_finding_count}</div>'
|
|
479
|
+
f'<div class="stat-label">{SCORECARD_LABEL_CAUGHT}</div></div>'
|
|
480
|
+
f'<div class="stat"><div class="stat-num">{round_count}</div>'
|
|
481
|
+
f'<div class="stat-label">{SCORECARD_LABEL_ROUNDS}</div></div>'
|
|
482
|
+
f'<div class="stat good"><div class="stat-num">{remaining_count}</div>'
|
|
483
|
+
f'<div class="stat-label">{SCORECARD_LABEL_REMAINING}</div></div>'
|
|
484
|
+
"</div>"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
459
488
|
def _render_scene_row(scene: dict, is_problem: bool) -> str:
|
|
460
489
|
"""Return one .scene row plus its caption for a problem-or-fix scene.
|
|
461
490
|
|
|
@@ -642,20 +671,26 @@ def _issue_class_sort_key(issue_class: dict) -> tuple[int, int]:
|
|
|
642
671
|
|
|
643
672
|
|
|
644
673
|
def _render_issue_class_heading(issue_class: dict) -> str:
|
|
645
|
-
"""Return the per-class heading with
|
|
674
|
+
"""Return the per-class heading with a category icon, plain name, and count.
|
|
646
675
|
|
|
647
676
|
Args:
|
|
648
677
|
issue_class: One issue-class dict from the summary.
|
|
649
678
|
|
|
650
679
|
Returns:
|
|
651
|
-
An HTML .bug-head block
|
|
680
|
+
An HTML .bug-head block: a category icon and the plain bug name grouped in
|
|
681
|
+
a .bug-title span, plus a finding-count chip.
|
|
652
682
|
"""
|
|
653
683
|
plain_name = html.escape(str(issue_class.get(ISSUE_CLASS_FIELD_PLAINNAME, "")))
|
|
654
684
|
count = _coerce_count(issue_class.get(ISSUE_CLASS_FIELD_COUNT, 0))
|
|
655
685
|
count_phrase = html.escape(_pluralize(count, "finding", "findings"))
|
|
686
|
+
category = str(issue_class.get(ISSUE_CLASS_FIELD_CATEGORY, CATEGORY_BUG))
|
|
687
|
+
icon = ISSUE_ICON_BY_CATEGORY.get(category, DEFAULT_ISSUE_ICON)
|
|
656
688
|
return (
|
|
657
689
|
'<div class="bug-head">'
|
|
690
|
+
'<span class="bug-title">'
|
|
691
|
+
f'<span class="bug-icon">{icon}</span>'
|
|
658
692
|
f'<span class="bug-name">{plain_name}</span>'
|
|
693
|
+
"</span>"
|
|
659
694
|
f'<span class="bug-count">{count_phrase}</span>'
|
|
660
695
|
"</div>"
|
|
661
696
|
)
|
|
@@ -792,9 +827,10 @@ def _render_summary_body(
|
|
|
792
827
|
final_sha_short: First eight characters of the final commit sha.
|
|
793
828
|
|
|
794
829
|
Returns:
|
|
795
|
-
An HTML body fragment: verdict banner,
|
|
796
|
-
caught section that opens with a
|
|
797
|
-
issue-class before/after panels,
|
|
830
|
+
An HTML body fragment: verdict banner, an at-a-glance scorecard,
|
|
831
|
+
problem/fix cards, then a single caught section that opens with a
|
|
832
|
+
run-stats lead line and holds the issue-class before/after panels,
|
|
833
|
+
followed by the collapsed appendix.
|
|
798
834
|
"""
|
|
799
835
|
convergence_summary = run_data.convergence_summary
|
|
800
836
|
if convergence_summary is None:
|
|
@@ -803,12 +839,14 @@ def _render_summary_body(
|
|
|
803
839
|
verdict_banner = _render_verdict_banner(
|
|
804
840
|
convergence_summary, run_data, final_sha_short
|
|
805
841
|
)
|
|
842
|
+
scorecard = _render_scorecard(run_data, round_count)
|
|
806
843
|
pf_grid = _render_pf_grid(convergence_summary)
|
|
807
844
|
caught_lead = _render_caught_lead(convergence_summary, run_data, round_count)
|
|
808
845
|
issue_panels = _render_issue_class_panels(convergence_summary)
|
|
809
846
|
appendix = _render_appendix(run_data.all_distinct_findings)
|
|
810
847
|
return (
|
|
811
848
|
f"{verdict_banner}"
|
|
849
|
+
f"{scorecard}"
|
|
812
850
|
f"<h2>What this PR does</h2>{pf_grid}"
|
|
813
851
|
f"<h2>What was caught — and how it looked</h2>{caught_lead}{issue_panels}"
|
|
814
852
|
f"{appendix}"
|
|
@@ -138,6 +138,49 @@ def test_cli_renders_verdict_banner_with_python_computed_vsub(tmp_path: Path) ->
|
|
|
138
138
|
assert "final commit 7c2f420c" in html_content
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
def test_cli_renders_scorecard_with_caught_rounds_and_zero_remaining(
|
|
142
|
+
tmp_path: Path,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Should render an at-a-glance scorecard: findings caught, rounds, and zero left."""
|
|
145
|
+
out_path = tmp_path / "report-scorecard.html"
|
|
146
|
+
|
|
147
|
+
completed = _render_cli(FIXTURE_JOURNAL, out_path)
|
|
148
|
+
assert completed.returncode == 0, f"CLI failed:\n{completed.stderr}"
|
|
149
|
+
|
|
150
|
+
html_content = out_path.read_text(encoding="utf-8")
|
|
151
|
+
assert 'class="scorecard"' in html_content
|
|
152
|
+
assert 'class="stat good"' in html_content
|
|
153
|
+
assert f'<div class="stat-num">{EXPECTED_TOTAL_FINDINGS}</div>' in html_content
|
|
154
|
+
assert f'<div class="stat-num">{EXPECTED_ROUND_COUNT}</div>' in html_content
|
|
155
|
+
assert '<div class="stat-num">0</div>' in html_content
|
|
156
|
+
assert ">caught</div>" in html_content
|
|
157
|
+
assert ">rounds</div>" in html_content
|
|
158
|
+
assert ">left</div>" in html_content
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_render_issue_class_heading_carries_a_category_icon() -> None:
|
|
162
|
+
"""Should prefix the plain name with the icon matching the issue category."""
|
|
163
|
+
bug_heading = render_report._render_issue_class_heading(
|
|
164
|
+
{"plainName": "A plain symptom", "count": 2, "category": "bug"}
|
|
165
|
+
)
|
|
166
|
+
assert 'class="bug-title"' in bug_heading
|
|
167
|
+
assert 'class="bug-icon"' in bug_heading
|
|
168
|
+
assert render_report.ISSUE_ICON_BY_CATEGORY["bug"] in bug_heading
|
|
169
|
+
|
|
170
|
+
standard_heading = render_report._render_issue_class_heading(
|
|
171
|
+
{"plainName": "A standard symptom", "count": 1, "category": "code-standard"}
|
|
172
|
+
)
|
|
173
|
+
assert render_report.ISSUE_ICON_BY_CATEGORY["code-standard"] in standard_heading
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_render_issue_class_heading_uses_default_icon_for_unknown_category() -> None:
|
|
177
|
+
"""Should fall back to the default icon when the category has no mapped icon."""
|
|
178
|
+
heading = render_report._render_issue_class_heading(
|
|
179
|
+
{"plainName": "An unmapped symptom", "count": 1, "category": "mystery"}
|
|
180
|
+
)
|
|
181
|
+
assert render_report.DEFAULT_ISSUE_ICON in heading
|
|
182
|
+
|
|
183
|
+
|
|
141
184
|
def test_cli_renders_problem_and_fix_scene_cards(tmp_path: Path) -> None:
|
|
142
185
|
"""Should draw problem and fix scene cards with trigger, result, and caption."""
|
|
143
186
|
out_path = tmp_path / "report-scenes.html"
|