1- """Terminal and HTML reporting."""
1+ """Terminal, HTML, and JSON reporting."""
22
33from __future__ import annotations
44
5+ from collections import Counter
6+ import json
57from math import pi
68from pathlib import Path
79
@@ -37,13 +39,79 @@ def _score_color(score: int) -> str:
3739 return "red"
3840
3941
42+ def _badge_markdown_color (score : int ) -> str :
43+ if score >= 75 :
44+ return "brightgreen"
45+ if score >= 40 :
46+ return "yellow"
47+ return "red"
48+
49+
4050def _score_bar (score : int , width : int = 24 ) -> str :
4151 filled = int ((score / 100 ) * width )
4252 empty = width - filled
4353 color = _score_color (score )
4454 return f"[{ color } ]{ '█' * filled } [/]{ '░' * empty } "
4555
4656
57+ def _issue_counts_by_severity (issues : list [Issue ]) -> dict [str , int ]:
58+ counts = {"high" : 0 , "medium" : 0 , "low" : 0 }
59+ for issue in issues :
60+ counts [issue .severity ] = counts .get (issue .severity , 0 ) + 1
61+ return counts
62+
63+
64+ def _issue_counts_by_category (issues : list [Issue ]) -> dict [str , int ]:
65+ ordered = Counter (issue .category for issue in issues )
66+ return dict (sorted (ordered .items (), key = lambda item : (- item [1 ], item [0 ].lower ())))
67+
68+
69+ def _hotspot_files (issues : list [Issue ], limit : int = 5 ) -> list [dict [str , int | str ]]:
70+ ranked = Counter (issue .file for issue in issues ).most_common (limit )
71+ return [{"file" : file_path , "issue_count" : count } for file_path , count in ranked ]
72+
73+
74+ def build_report_payload (report : AnalysisReport , roast : RoastResult ) -> dict [str , object ]:
75+ overall_score = report .scores .get ("Overall" , 0 )
76+ severity_counts = _issue_counts_by_severity (report .issues )
77+ category_counts = _issue_counts_by_category (report .issues )
78+ hotspots = _hotspot_files (report .issues )
79+ badge_color = _badge_markdown_color (overall_score )
80+
81+ return {
82+ "summary" : {
83+ "total_files" : report .total_files ,
84+ "total_lines" : report .total_lines ,
85+ "total_issues" : len (report .issues ),
86+ "scores" : report .scores ,
87+ },
88+ "counts" : {
89+ "by_severity" : severity_counts ,
90+ "by_category" : category_counts ,
91+ },
92+ "hotspots" : hotspots ,
93+ "roast" : {
94+ "headline" : roast .headline ,
95+ "roast_lines" : roast .roast_lines ,
96+ "verdict" : roast .verdict ,
97+ "verdict_emoji" : roast .verdict_emoji ,
98+ },
99+ "share" : {
100+ "badge_markdown" : f"" ,
101+ },
102+ "issues" : [
103+ {
104+ "file" : issue .file ,
105+ "line" : issue .line ,
106+ "category" : issue .category ,
107+ "severity" : issue .severity ,
108+ "description" : issue .description ,
109+ }
110+ for issue in sorted (report .issues , key = lambda item : (_severity_order (item ), item .file , item .line or 0 ))
111+ ],
112+ }
113+
114+
47115def render_terminal_report (
48116 report : AnalysisReport ,
49117 roast : RoastResult ,
@@ -67,6 +135,23 @@ def render_terminal_report(
67135 )
68136 console .print (score_table )
69137
138+ severity_counts = _issue_counts_by_severity (report .issues )
139+ category_counts = _issue_counts_by_category (report .issues )
140+ hotspots = _hotspot_files (report .issues , limit = 3 )
141+ category_summary = ", \" .join(f" {name }: {count }" for name, count in category_counts.items()) or " No categories to summarize ."
142+ hotspot_summary = " , \".join(f" {item ['file' ]} ({item ['issue_count' ]})" for item in hotspots) or " No hotspots found ."
143+ console .print (
144+ Panel .fit (
145+ Text .from_markup (
146+ f"[bold red]High:[/] { severity_counts ['high' ]} [bold yellow]Medium:[/] { severity_counts ['medium' ]} [bold green]Low:[/] { severity_counts ['low' ]} \n "
147+ f"[bold]Categories:[/] { category_summary } \n "
148+ f"[bold]Hotspots:[/] { hotspot_summary } "
149+ ),
150+ title = "Summary" ,
151+ border_style = "cyan" ,
152+ )
153+ )
154+
70155 issues_table = Table (title = "Top 10 Issues" , box = box .SIMPLE )
71156 issues_table .add_column ("File" , overflow = "fold" )
72157 issues_table .add_column ("Line" , justify = "right" , width = 6 )
@@ -124,11 +209,12 @@ def export_html_report(
124209 )
125210 template = env .get_template ("report.html" )
126211
212+ payload = build_report_payload (report , roast )
127213 overall_score = report .scores .get ("Overall" , 0 )
128214 radius = 90
129215 circumference = 2 * pi * radius
130216 dash_offset = circumference * (1 - (overall_score / 100 ))
131- issue_rows = sorted ( report . issues , key = lambda issue : ( _severity_order ( issue ), issue . file , issue . line or 0 ))
217+ issue_rows = payload [ " issues" ]
132218 score_items = [
133219 {
134220 "name" : "AI Slop" ,
@@ -147,19 +233,34 @@ def export_html_report(
147233 },
148234 ]
149235
150- badge_markdown = f""
151236 rendered = template .render (
152237 report = report ,
153238 roast = roast ,
154239 overall_score = overall_score ,
155240 score_items = score_items ,
156241 issues = issue_rows ,
242+ severity_counts = payload ["counts" ]["by_severity" ],
243+ category_counts = payload ["counts" ]["by_category" ],
244+ hotspots = payload ["hotspots" ],
245+ total_issues = payload ["summary" ]["total_issues" ],
157246 score_ring_circumference = f"{ circumference :.2f} " ,
158247 score_ring_offset = f"{ dash_offset :.2f} " ,
159248 overall_color = _badge_color (overall_score ),
160- badge_markdown = badge_markdown ,
249+ badge_markdown = payload [ "share" ][ " badge_markdown" ] ,
161250 )
162251
163252 output = Path (output_path ).expanduser ().resolve ()
164253 output .write_text (rendered , encoding = "utf-8" )
165254 return output
255+
256+
257+ def export_json_report (
258+ report : AnalysisReport ,
259+ roast : RoastResult ,
260+ output_path : str | Path ,
261+ ) -> Path :
262+ """Export a machine-readable JSON report."""
263+ payload = build_report_payload (report , roast )
264+ output = Path (output_path ).expanduser ().resolve ()
265+ output .write_text (json .dumps (payload , indent = 2 ) + "\n " , encoding = "utf-8" )
266+ return output
0 commit comments