Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ build/
.gstack/
website/docs/
website/zh/docs/
.context
61 changes: 46 additions & 15 deletions dashboard/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -135,6 +145,8 @@ def render_badge(status: str) -> str:
return '<span class="badge pass">&#10003; PASS</span>'
if status == "FAIL":
return '<span class="badge fail">&#10007; FAIL</span>'
if status == "ERROR":
return '<span class="badge error">&#9888; PARSE ERR</span>'
return '<span class="badge warn">&#9888; WARN</span>'


Expand All @@ -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 (
'<div class="compliance-banner has-parse-errors" role="alert">'
'<span class="icon" aria-hidden="true">⚠️</span>'
'<div><div class="verdict">PARSE ERRORS</div>'
f'<div class="sub">{desc}</div></div>'
"</div>"
)
sub = f"All {total_scans} scan{'s' if total_scans != 1 else ''} passed. No critical issues."
return (
'<div class="compliance-banner all-clear" role="status" aria-live="polite">'
Expand Down Expand Up @@ -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'<span class="parse-error-msg">{_escape(scan["error"])}</span>'
else:
details = render_findings_html(scan["findings"])
row = (
f"<tr{row_class}>"
f"<td>{render_badge(scan['status'])}</td>"
Expand All @@ -205,7 +238,7 @@ def render_table(scans: list[dict]) -> str:
f"<td>{render_count(scan['critical'], 'critical')}</td>"
f"<td>{render_count(scan['high'], 'high')}</td>"
f"<td>{render_count(scan['medium'], '')}</td>"
f"<td>{render_findings_html(scan['findings'])}</td>"
f"<td>{details}</td>"
f"</tr>"
)
rows_html.append(row)
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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)}")

Expand Down
18 changes: 14 additions & 4 deletions dashboard/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────── */
Expand All @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 24 additions & 5 deletions src/cast_cli/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""CAST CLI — entry point."""

import sys
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 13 additions & 8 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import json
import sys
import pytest
from pathlib import Path

# Add project root to path for importing dashboard module
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading