From c6afc9ae34819803b0e4a9cf98ea5391c1edffa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 11:10:30 +0000 Subject: [PATCH 1/2] feat: cast init interactive menu + dashboard parse-error fix cast init: show a numbered type-selection menu when project detection fails in a TTY environment; non-TTY/CI keeps the existing exit-1 behavior. Dashboard: parse_sarif() now returns an ERROR dict instead of None on JSON/IO failure. ERROR rows are rendered in the table with an amber PARSE ERR badge and the error message. The compliance banner shows PARSE ERRORS instead of ALL CLEAR when unresolved parse failures exist. Updated tests to match new behavior (65 passing). https://claude.ai/code/session_01PovvcUbam7c1xDTrEPRhT7 --- dashboard/generate.py | 61 +++++++++++++++++++++++++++++++---------- dashboard/template.html | 18 +++++++++--- src/cast_cli/main.py | 29 ++++++++++++++++---- tests/test_dashboard.py | 20 +++++++++----- 4 files changed, 97 insertions(+), 31 deletions(-) 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..933629a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -62,11 +62,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 +123,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" From e840c8018f8dac0335d084a62d8758c18f674708 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 28 Mar 2026 16:16:41 +0200 Subject: [PATCH 2/2] fix: remove unused pytest import from test_dashboard.py --- .gitignore | 1 + tests/test_dashboard.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) 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/tests/test_dashboard.py b/tests/test_dashboard.py index 933629a..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