|
| 1 | +from __future__ import annotations |
| 2 | + |
1 | 3 | import argparse |
2 | 4 | import json |
| 5 | +import pathlib |
3 | 6 | import sys |
4 | | -from pathlib import Path |
5 | | -from typing import Any |
6 | | - |
7 | | -from rich.box import ROUNDED |
8 | | -from rich.console import Console |
9 | | -from rich.table import Table |
10 | | -from rich.text import Text |
11 | | - |
12 | | -from .report import to_markdown |
13 | | - |
14 | | -_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] |
15 | | - |
16 | | - |
17 | | -def _risk_emoji(risk: str) -> str: |
18 | | - return {"red": "🔴", "yellow": "🟡", "green": "🟢"}.get(risk, "🟢") |
19 | | - |
20 | | - |
21 | | -def _exit_code(risk: str) -> int: |
22 | | - return {"green": 0, "yellow": 1, "red": 2}.get(risk, 0) |
23 | | - |
24 | | - |
25 | | -def _summarize(apv: dict) -> dict[str, int][str, int]: |
26 | | - counts: dict[str, int][str, int] = {} |
27 | | - for k, v in (apv.get("by_severity") or {}).items(): |
28 | | - counts[str(k).upper()] = int(v or 0) |
29 | | - total = sum(counts.get(s, 0) for s in _SEVERITIES) |
30 | | - worst = next((s for s in _SEVERITIES if counts.get(s, 0) > 0), "INFO") |
31 | | - risk = str(apv.get("risk_level") or apv.get("risk") or "green").lower() |
32 | | - return {"total": total, "by_severity": counts, "worst": worst, "risk_level": risk} |
33 | | - |
34 | | - |
35 | | -def _table_plain(summary: dict[str, Any]) -> str: |
36 | | - counts: dict[str, int][str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES} |
37 | | - total = int(summary["total"]) |
38 | | - w_sev = max(len("Severity"), max(len(s) for s in _SEVERITIES)) |
39 | | - w_cnt = max(len("Count"), len(str(total))) |
40 | | - header = f'{"Severity".ljust(w_sev)} {"Count".rjust(w_cnt)} {"Share":>5}' |
41 | | - sep = f'{"-"*w_sev} {"-"*w_cnt} {"-"*5}' |
42 | | - lines = [header, sep] |
43 | | - for s in _SEVERITIES: |
44 | | - n = counts.get(s, 0) |
45 | | - pct = f"{(n/total*100):.0f}%" if total else "0%" |
46 | | - lines.append(f"{s.ljust(w_sev)} {str(n).rjust(w_cnt)} {pct:>5}") |
47 | | - lines.append( |
48 | | - f'{"TOTAL".ljust(w_sev)} {str(total).rjust(w_cnt)} ' f'{"100%" if total else "0%":>5}' |
49 | | - ) |
50 | | - return "\n".join(lines) |
51 | | - |
52 | | - |
53 | | -def _bar_plain(summary: dict[str, Any], width: int = 80) -> str: |
54 | | - counts: dict[str, int][str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES} |
55 | | - total = int(summary["total"]) |
56 | | - maxc = max(counts.values()) if counts else 0 |
57 | | - bar_w = max(10, min(40, width - 24)) |
58 | | - lines = [] |
59 | | - for s in _SEVERITIES: |
60 | | - n = counts.get(s, 0) |
61 | | - w = 0 if maxc == 0 or n == 0 else max(1, round(n / maxc * bar_w)) |
62 | | - pct = f"{(n/total*100):.0f}%" if total else "0%" |
63 | | - lines.append(f"{s:<8} {str(n).rjust(4)} {pct:>4} " + ("█" * w)) |
64 | | - lines.append(f'{"TOTAL":<8} {str(total).rjust(4)} 100%') |
65 | | - return "\n".join(lines) |
66 | | - |
67 | | - |
68 | | -def _table_rich(summary: dict[str, Any], width: int) -> Table: |
69 | | - counts: dict[str, int][str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES} |
70 | | - total = int(summary["total"]) |
71 | | - worst = str(summary.get("worst", "UNKNOWN")).upper() |
72 | | - risk = str(summary.get("risk", summary.get("risk_level", "green")) or "green").lower() |
73 | | - emoji = _risk_emoji(risk) |
74 | | - colors = { |
75 | | - "CRITICAL": "bold bright_red", |
76 | | - "HIGH": "red3", |
77 | | - "MEDIUM": "yellow3", |
78 | | - "LOW": "green3", |
79 | | - "INFO": "cyan3", |
80 | | - } |
81 | | - maxc = max(counts.values()) if counts else 0 |
82 | | - bar_w = max(10, min(32, width - 42)) |
83 | | - |
84 | | - def bar(n: int) -> str: |
85 | | - if maxc == 0 or n == 0: |
86 | | - return "" |
87 | | - w = max(1, round(n / maxc * bar_w)) |
88 | | - return "█" * w |
89 | | - |
90 | | - title = Text.assemble( |
91 | | - ("Diff Risk Dashboard ", "bold"), |
92 | | - (emoji + " ",), |
93 | | - ("— Worst: ", "dim"), |
94 | | - (worst, "bold"), |
95 | | - ) |
96 | | - table = Table( |
97 | | - title=title, |
98 | | - header_style="bold cyan", |
99 | | - box=ROUNDED, |
100 | | - expand=True, |
101 | | - show_lines=False, |
102 | | - pad_edge=False, |
103 | | - ) |
104 | | - table.add_column("Severity", justify="left", no_wrap=True) |
105 | | - table.add_column("Count", justify="right") |
106 | | - table.add_column("Share", justify="right") |
107 | | - table.add_column("Bar", justify="left", no_wrap=True) |
108 | | - for s in _SEVERITIES: |
109 | | - n = counts.get(s, 0) |
110 | | - pct = f"{(n/total*100):.0f}%" if total else "0%" |
111 | | - col = colors.get(s, "") |
112 | | - table.add_row( |
113 | | - f"[{col}]{s}[/]", |
114 | | - f"[{col}]{n}[/]", |
115 | | - f"[{col}]{pct}[/]", |
116 | | - f"[{col}]{bar(n)}[/]", |
117 | | - ) |
118 | | - table.add_row( |
119 | | - "[bold]TOTAL[/bold]", |
120 | | - f"[bold]{total}[/bold]", |
121 | | - "[bold]100%[/bold]" if total else "0%", |
122 | | - "", |
123 | | - ) |
124 | | - return table |
125 | | - |
126 | | - |
127 | | -def _bar_rich(summary: dict[str, Any], width: int) -> None: |
128 | | - console = Console() |
129 | | - counts: dict[str, int][str, int] = {s: int(summary["by_severity"].get(s, 0)) for s in _SEVERITIES} |
130 | | - total = int(summary["total"]) |
131 | | - maxc = max(counts.values()) if counts else 0 |
132 | | - bar_w = max(10, min(40, width - 24)) |
133 | | - colors = { |
134 | | - "CRITICAL": "bright_red", |
135 | | - "HIGH": "red3", |
136 | | - "MEDIUM": "yellow3", |
137 | | - "LOW": "green3", |
138 | | - "INFO": "cyan3", |
139 | | - } |
140 | | - for s in _SEVERITIES: |
141 | | - n = counts.get(s, 0) |
142 | | - w = 0 if maxc == 0 or n == 0 else max(1, round(n / maxc * bar_w)) |
143 | | - pct = f"{(n/total*100):.0f}%" if total else "0%" |
144 | | - console.print( |
145 | | - f"[{colors[s]}]{s:<8}[/] " |
146 | | - f"[{colors[s]}]{n:>4} {pct:>4}[/] " |
147 | | - f"[{colors[s]}]{'█'*w}[/]" |
148 | | - ) |
149 | | - console.print(f"[bold]TOTAL[/bold] {total:>4} 100%") |
150 | | - |
151 | | - |
152 | | -def main() -> int: |
153 | | - p = argparse.ArgumentParser( |
154 | | - prog="diff_risk_dashboard", |
155 | | - description="Diff Risk Dashboard (APV JSON -> summary)", |
156 | | - ) |
157 | | - p.add_argument("input", help="Path o texto JSON de ai-patch-verifier") |
158 | | - p.add_argument( |
159 | | - "-f", |
160 | | - "--format", |
161 | | - choices=["table", "json", "md", "bar"], |
162 | | - default="table", |
163 | | - help="Formato de salida", |
164 | | - ) |
165 | | - p.add_argument( |
166 | | - "-o", |
167 | | - "--output", |
168 | | - default="-", |
169 | | - help="Archivo de salida; '-' = stdout", |
170 | | - ) |
171 | | - p.add_argument( |
172 | | - "--no-exit-by-risk", |
173 | | - action="store_true", |
174 | | - help="No ajustar el exit code por nivel de riesgo", |
175 | | - ) |
176 | | - args = p.parse_args() |
177 | | - |
178 | | - apv = ( |
179 | | - json.loads(Path(args.input).read_text(encoding="utf-8")) |
180 | | - if Path(args.input).exists() |
181 | | - else json.loads(args.input) |
182 | | - ) |
183 | | - summary = _summarize(apv) |
184 | | - fmt = args.format.lower() |
185 | | - out: str | None = None |
186 | | - |
187 | | - if fmt == "table": |
188 | | - if args.output == "-" and sys.stdout.isatty(): |
189 | | - console = Console() |
190 | | - console.print(_table_rich(summary, console.width)) |
191 | | - console.print( |
192 | | - Text( |
193 | | - "Tip: usa -f md para reporte Markdown o -f json para máquinas.", |
194 | | - style="dim", |
195 | | - ) |
196 | | - ) |
197 | | - else: |
198 | | - out = _table_plain(summary) + "\n" |
199 | | - elif fmt == "bar": |
200 | | - if args.output == "-" and sys.stdout.isatty(): |
201 | | - _bar_rich(summary, Console().width) |
202 | | - else: |
203 | | - out = _bar_plain(summary) + "\n" |
204 | | - elif fmt == "json": |
205 | | - out = json.dumps(summary, indent=2, ensure_ascii=False) + "\n" |
206 | | - elif fmt == "md": |
207 | | - out = to_markdown(summary) + "\n" |
| 7 | +from collections.abc import Mapping |
| 8 | +from typing import Any, TypedDict, cast |
| 9 | + |
| 10 | +from .report import to_json, to_markdown |
| 11 | + |
| 12 | + |
| 13 | +class SeveritySummary(TypedDict): |
| 14 | + total: int |
| 15 | + by_severity: Mapping[str, int] |
| 16 | + |
| 17 | + |
| 18 | +def _extract_findings(data: object) -> list[Mapping[str, Any]]: |
| 19 | + if isinstance(data, list): |
| 20 | + return [cast(Mapping[str, Any], x) for x in data] |
| 21 | + if isinstance(data, dict): |
| 22 | + maybe = data.get("findings") |
| 23 | + if isinstance(maybe, list): |
| 24 | + return [cast(Mapping[str, Any], x) for x in maybe] |
| 25 | + return [] |
| 26 | + |
| 27 | + |
| 28 | +def summarize_apv_json(data: object) -> SeveritySummary: |
| 29 | + findings = _extract_findings(data) |
| 30 | + counts: dict[str, int] = {} |
| 31 | + for f in findings: |
| 32 | + sev = str(f.get("severity", "unknown")).lower() |
| 33 | + counts[sev] = counts.get(sev, 0) + 1 |
| 34 | + return {"total": len(findings), "by_severity": cast(Mapping[str, int], counts)} |
| 35 | + |
| 36 | + |
| 37 | +def main(argv: list[str] | None = None) -> int: |
| 38 | + parser = argparse.ArgumentParser(prog="diff-risk") |
| 39 | + parser.add_argument("input", help="APV JSON file") |
| 40 | + parser.add_argument("-f", "--format", choices=["md", "json"], default="md") |
| 41 | + parser.add_argument("-o", "--output", help="Output path", default="-") |
| 42 | + args = parser.parse_args(argv) |
| 43 | + |
| 44 | + data = json.loads(pathlib.Path(args.input).read_text(encoding="utf-8")) |
| 45 | + summary = summarize_apv_json(data) |
| 46 | + rendered = to_markdown(summary) if args.format == "md" else to_json(summary) + "\n" |
| 47 | + |
| 48 | + if args.output == "-" or args.output == "": |
| 49 | + sys.stdout.write(rendered) |
208 | 50 | else: |
209 | | - out = _table_plain(summary) + "\n" |
210 | | - |
211 | | - if out is not None: |
212 | | - if args.output == "-": |
213 | | - sys.stdout.write(out) |
214 | | - else: |
215 | | - Path(args.output).parent.mkdir(parents=True, exist_ok=True) |
216 | | - Path(args.output).write_text(out, encoding="utf-8") |
217 | | - print(f"Wrote {args.output}") |
218 | | - |
219 | | - if not args.no_exit_by_risk: |
220 | | - return _exit_code(str(summary.get("risk", summary.get("risk_level", "green"))).lower()) |
| 51 | + pathlib.Path(args.output).write_text(rendered, encoding="utf-8") |
| 52 | + |
| 53 | + try: |
| 54 | + from rich.console import Console |
| 55 | + from rich.text import Text |
| 56 | + |
| 57 | + Console().print(Text.assemble("Wrote ", (str(args.output), "bold"))) |
| 58 | + except Exception: |
| 59 | + pass |
221 | 60 | return 0 |
222 | 61 |
|
223 | 62 |
|
|
0 commit comments