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"