diff --git a/.gitignore b/.gitignore index 209365a..6c8b8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .gstack/ website/docs/ website/zh/docs/ +.context diff --git a/dashboard/generate.py b/dashboard/generate.py index 0707bd1..5037c25 100644 --- a/dashboard/generate.py +++ b/dashboard/generate.py @@ -20,7 +20,17 @@ def parse_sarif(sarif_path: Path) -> dict: data = json.loads(sarif_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: print(f"Warning: could not parse {sarif_path}: {e}", file=sys.stderr) - return None + return { + "name": sarif_path.stem, + "tools": [], + "status": "ERROR", + "error": str(e), + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "findings": [], + } findings = [] tools = set() @@ -135,6 +145,8 @@ def render_badge(status: str) -> str: return '✓ PASS' if status == "FAIL": return '✗ FAIL' + if status == "ERROR": + return '⚠ PARSE ERR' return '⚠ WARN' @@ -153,10 +165,22 @@ def _escape(text: str) -> str: ) -def render_compliance_banner(failing: int, total_critical: int, total_scans: int) -> str: +def render_compliance_banner(failing: int, total_critical: int, total_scans: int, parse_errors: int = 0) -> str: if total_scans == 0: return "" if failing == 0 and total_critical == 0: + if parse_errors > 0: + desc = ( + f"{parse_errors} SARIF file{'s' if parse_errors != 1 else ''} could not be parsed. " + "Results may be incomplete." + ) + return ( + '" + ) sub = f"All {total_scans} scan{'s' if total_scans != 1 else ''} passed. No critical issues." return ( '
' @@ -194,7 +218,16 @@ def render_empty_state() -> str: def render_table(scans: list[dict]) -> str: rows_html = [] for scan in scans: - row_class = ' class="row-fail"' if scan["status"] == "FAIL" else "" + if scan["status"] == "FAIL": + row_class = ' class="row-fail"' + elif scan["status"] == "ERROR": + row_class = ' class="row-error"' + else: + row_class = "" + if scan.get("error"): + details = f'{_escape(scan["error"])}' + else: + details = render_findings_html(scan["findings"]) row = ( f"" f"{render_badge(scan['status'])}" @@ -205,7 +238,7 @@ def render_table(scans: list[dict]) -> str: f"{render_count(scan['critical'], 'critical')}" f"{render_count(scan['high'], 'high')}" f"{render_count(scan['medium'], '')}" - f"{render_findings_html(scan['findings'])}" + f"{details}" f"" ) rows_html.append(row) @@ -237,15 +270,7 @@ def generate_dashboard( if not sarif_files: print(f"No .sarif files found in {sarif_dir}", file=sys.stderr) - scans = [r for f in sarif_files if (r := parse_sarif(f)) is not None] - - if sarif_files and not scans: - print( - f"Error: found {len(sarif_files)} SARIF file(s) but all failed to parse. " - "Dashboard not generated to avoid false 'ALL CLEAR' output.", - file=sys.stderr, - ) - sys.exit(1) + scans = [parse_sarif(f) for f in sarif_files] # Override the scan name with project_name if only one SARIF file if project_name and len(scans) == 1: @@ -254,11 +279,12 @@ def generate_dashboard( total_critical = sum(s["critical"] for s in scans) total_high = sum(s["high"] for s in scans) failing = sum(1 for s in scans if s["status"] == "FAIL") + parse_errors = sum(1 for s in scans if s["status"] == "ERROR") generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") sha_short = commit_sha[:12] if len(commit_sha) > 12 else commit_sha - compliance_banner = render_compliance_banner(failing, total_critical, len(scans)) + compliance_banner = render_compliance_banner(failing, total_critical, len(scans), parse_errors) table_or_empty = render_table(scans) if scans else render_empty_state() template_path = Path(__file__).parent / "template.html" @@ -278,7 +304,12 @@ def generate_dashboard( output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html, encoding="utf-8") - status = "ALL CLEAR" if failing == 0 and total_critical == 0 else f"{failing} FAILING" + if parse_errors > 0 and failing == 0 and total_critical == 0: + status = f"PARSE ERRORS ({parse_errors})" + elif failing == 0 and total_critical == 0: + status = "ALL CLEAR" + else: + status = f"{failing} FAILING" print(f"Dashboard written to {output_path}") print(f" Status: {status} | Critical: {total_critical} | High: {total_high} | Scans: {len(scans)}") diff --git a/dashboard/template.html b/dashboard/template.html index ef3dbe9..d01696c 100644 --- a/dashboard/template.html +++ b/dashboard/template.html @@ -19,6 +19,8 @@ --high-bg: #3d2a1a; --pass-bg: #1a2e1a; --fail-row-bg: rgba(248, 81, 73, 0.05); + --error-row-bg: rgba(210, 153, 34, 0.07); + --warn-bg: #2d2200; } * { box-sizing: border-box; margin: 0; padding: 0; } body { @@ -64,6 +66,10 @@ background: var(--critical-bg); border-color: var(--red); } + .compliance-banner.has-parse-errors { + background: var(--warn-bg); + border-color: var(--yellow); + } .compliance-banner .icon { font-size: 22px; flex-shrink: 0; } .compliance-banner .verdict { font-size: 17px; font-weight: 700; } .compliance-banner .sub { font-size: 12px; color: var(--muted); margin-top: 2px; } @@ -123,7 +129,8 @@ thead th[scope="col"] {} tbody tr { border-bottom: 1px solid var(--border); } tbody tr:last-child { border-bottom: none; } - tbody tr.row-fail { background: var(--fail-row-bg); } + tbody tr.row-fail { background: var(--fail-row-bg); } + tbody tr.row-error { background: var(--error-row-bg); } tbody td { padding: 12px 14px; vertical-align: top; } /* ── Badges ──────────────────────────────────────────────────────────── */ @@ -137,9 +144,10 @@ border-radius: 20px; white-space: nowrap; } - .badge.pass { background: var(--pass-bg); color: var(--green); } - .badge.fail { background: var(--critical-bg); color: var(--red); } - .badge.warn { background: var(--high-bg); color: var(--yellow); } + .badge.pass { background: var(--pass-bg); color: var(--green); } + .badge.fail { background: var(--critical-bg); color: var(--red); } + .badge.warn { background: var(--high-bg); color: var(--yellow); } + .badge.error { background: var(--warn-bg); color: var(--yellow); border: 1px solid rgba(210,153,34,0.3); } /* ── Counts ──────────────────────────────────────────────────────────── */ .count { font-weight: 700; } @@ -211,6 +219,8 @@ margin-top: 2px; } + .parse-error-msg { color: var(--yellow); font-size: 12px; font-family: monospace; } + /* ── Empty state ─────────────────────────────────────────────────────── */ .empty-state { text-align: center; diff --git a/src/cast_cli/main.py b/src/cast_cli/main.py index 012be9e..93646d8 100644 --- a/src/cast_cli/main.py +++ b/src/cast_cli/main.py @@ -1,5 +1,6 @@ """CAST CLI — entry point.""" +import sys from pathlib import Path from typing import Optional @@ -34,6 +35,21 @@ COMING_SOON = ["docker"] +def _prompt_type_selection(console: Console) -> str: + """Show a numbered menu and return the chosen project type.""" + console.print("[yellow]Could not detect project type.[/yellow]") + console.print("\nSelect a project type:\n") + choices = list(SUPPORTED_TYPES.items()) + for i, (t, desc) in enumerate(choices, 1): + console.print(f" [{i}] {t} — {desc}") + console.print() + while True: + raw = input(f"Enter number [1-{len(choices)}]: ").strip() + if raw.isdigit() and 1 <= int(raw) <= len(choices): + return choices[int(raw) - 1][0] + console.print("[red]Invalid choice. Please enter a number from the list.[/red]") + + @app.command() def version() -> None: """Show CAST version.""" @@ -70,11 +86,14 @@ def init( detected = project_type or detect_project(Path(".")) if detected is None: - console.print("[yellow]Could not detect project type.[/yellow]") - console.print("Use [bold]--type[/bold] to specify one:") - for t, desc in SUPPORTED_TYPES.items(): - console.print(f" cast init --type {t} ({desc})") - raise typer.Exit(1) + if sys.stdout.isatty(): + detected = _prompt_type_selection(console) + else: + console.print("[yellow]Could not detect project type.[/yellow]") + console.print("Use [bold]--type[/bold] to specify one:") + for t, desc in SUPPORTED_TYPES.items(): + console.print(f" cast init --type {t} ({desc})") + raise typer.Exit(1) if detected in COMING_SOON: console.print( diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7d363aa..b584f71 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -2,7 +2,6 @@ import json import sys -import pytest from pathlib import Path # Add project root to path for importing dashboard module @@ -62,11 +61,16 @@ def test_findings_list_populated(self, tmp_path): assert len(result["findings"]) == 1 assert result["findings"][0]["rule_id"] == "rule-abc" - def test_invalid_json_returns_none(self, tmp_path): + def test_invalid_json_returns_error_dict(self, tmp_path): bad_file = tmp_path / "bad.sarif" bad_file.write_text("this is not json {{{") result = parse_sarif(bad_file) - assert result is None + assert result is not None + assert result["status"] == "ERROR" + assert "error" in result + assert result["name"] == "bad" + assert result["critical"] == 0 + assert result["findings"] == [] def test_missing_runs_key_returns_empty_pass(self, tmp_path): sarif_file = tmp_path / "empty.sarif" @@ -118,16 +122,17 @@ def test_creates_output_parent_directories(self, tmp_path): generate_dashboard(sarif_dir, output) assert output.exists() - def test_all_sarif_fail_to_parse_exits_nonzero(self, tmp_path, capsys): + def test_all_sarif_fail_to_parse_shows_parse_error_in_html(self, tmp_path): sarif_dir = tmp_path / "sarif" sarif_dir.mkdir() bad = sarif_dir / "bad.sarif" bad.write_text("not json") output = tmp_path / "dashboard.html" - with pytest.raises(SystemExit) as exc_info: - generate_dashboard(sarif_dir, output) - assert exc_info.value.code == 1 - assert not output.exists() + generate_dashboard(sarif_dir, output) + assert output.exists() + html = output.read_text() + assert "PARSE ERR" in html + assert "ALL CLEAR" not in html def test_empty_sarif_dir_generates_empty_state_html(self, tmp_path): sarif_dir = tmp_path / "sarif"