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
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,42 @@ jobs:
name: binary-${{ matrix.target }}
path: ${{ env.ARCHIVE }}

# ── Baseline snapshot ─────────────────────────────────────────────────
capture-snapshot:
name: Capture baseline snapshot
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2

- name: Capture snapshot
env:
TAG: ${{ github.ref_name }}
run: cargo run --release -- snapshot capture --name "$TAG"

- uses: actions/upload-artifact@v4
with:
name: snapshot
path: snapshots/

- name: Commit snapshot to main
env:
TAG: ${{ github.ref_name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add snapshots/
git diff --cached --quiet && exit 0
git commit -m "chore: capture baseline snapshot for $TAG"
git push origin HEAD:main || true

# ── Compliance report (HTML export) ───────────────────────────────────
# Uses snapshots already committed from PREVIOUS releases (not the one
# just captured). The new snapshot is for future release comparisons.
build-compliance:
name: Build compliance report
runs-on: ubuntu-latest
Expand Down
17 changes: 16 additions & 1 deletion rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2721,7 +2721,21 @@ fn cmd_export_html(
// Load project state using the same pipeline as `rivet serve`.
let state = serve::reload_state(&project_path, &schemas_dir, 0)
.context("loading project for export")?;
let ctx = state.as_render_context();

// Auto-detect baseline snapshot for delta rendering.
let snap_dir = project_path.join("snapshots");
let baseline_snapshot = find_latest_snapshot(&snap_dir)
.ok()
.and_then(|path| rivet_core::snapshot::read_from_file(&path).ok());
if let Some(ref snap) = baseline_snapshot {
eprintln!(
"delta: comparing against baseline {} ({})",
snap.git_commit_short, snap.created_at,
);
}

let mut ctx = state.as_render_context();
ctx.baseline = baseline_snapshot.as_ref();
let params = ViewParams::default();

// SC-EMBED-1: warn when working tree is dirty.
Expand Down Expand Up @@ -5667,6 +5681,7 @@ fn cmd_lsp(cli: &Cli) -> Result<bool> {
externals: &externals,
project_path: &project_dir,
schemas_dir: &schemas_dir,
baseline: None,
};

let result = crate::render::render_page(&ctx, page, &view_params);
Expand Down
61 changes: 45 additions & 16 deletions rivet-cli/src/render/coverage.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::fmt::Write as _;

use rivet_core::coverage;
use rivet_core::document::html_escape;

Expand Down Expand Up @@ -44,8 +46,15 @@ pub(crate) fn render_coverage_view(ctx: &RenderContext) -> String {
return html;
}

let bl = ctx.baseline;
let has_delta = bl.is_some();

html.push_str("<div class=\"card\"><h3>Coverage by Rule</h3>");
html.push_str("<table><thead><tr><th>Rule</th><th>Source Type</th><th>Link</th><th>Direction</th><th>Coverage</th><th style=\"width:30%\">Progress</th></tr></thead><tbody>");
if has_delta {
html.push_str("<table><thead><tr><th>Rule</th><th>Source Type</th><th>Link</th><th>Direction</th><th>Coverage</th><th>Δ</th><th style=\"width:25%\">Progress</th></tr></thead><tbody>");
} else {
html.push_str("<table><thead><tr><th>Rule</th><th>Source Type</th><th>Link</th><th>Direction</th><th>Coverage</th><th style=\"width:30%\">Progress</th></tr></thead><tbody>");
}

for entry in &report.entries {
let pct = entry.percentage();
Expand All @@ -62,28 +71,48 @@ pub(crate) fn render_coverage_view(ctx: &RenderContext) -> String {
coverage::CoverageDirection::Backward => "backward",
};

html.push_str(&format!(
let delta_cell = if has_delta {
let base_pct = bl
.and_then(|s| s.coverage.rules.iter().find(|r| r.rule == entry.rule_name))
.map_or(0.0, |r| r.percentage);
let diff = pct - base_pct;
if diff.abs() < 0.05 {
"<td>—</td>".to_string()
} else {
let (sign, color) = if diff > 0.0 {
("+", "#15713a")
} else {
("", "#c62828")
};
format!("<td style=\"color:{color};font-weight:600\">{sign}{diff:.1}%</td>")
}
} else {
String::new()
};

let _ = write!(
html,
"<tr>\
<td title=\"{}\">{}</td>\
<td>{}</td>\
<td><span class=\"link-pill\">{}</span></td>\
<td>{}</td>\
<td><span class=\"badge {badge_class}\">{}/{} ({:.1}%)</span></td>\
<td title=\"{desc}\">{name}</td>\
<td>{source}</td>\
<td><span class=\"link-pill\">{link}</span></td>\
<td>{dir}</td>\
<td><span class=\"badge {badge_class}\">{covered}/{total} ({pct:.1}%)</span></td>\
{delta_cell}\
<td>\
<div style=\"background:#e5e5ea;border-radius:4px;height:18px;position:relative;overflow:hidden\">\
<div style=\"background:{bar_color};height:100%;width:{pct:.1}%;border-radius:4px;transition:width .3s ease\"></div>\
</div>\
</td>\
</tr>",
html_escape(&entry.description),
html_escape(&entry.rule_name),
badge_for_type(&entry.source_type),
html_escape(&entry.link_type),
dir_label,
entry.covered,
entry.total,
pct,
));
desc = html_escape(&entry.description),
name = html_escape(&entry.rule_name),
source = badge_for_type(&entry.source_type),
link = html_escape(&entry.link_type),
dir = dir_label,
covered = entry.covered,
total = entry.total,
);
}

html.push_str("</tbody></table></div>");
Expand Down
2 changes: 2 additions & 0 deletions rivet-cli/src/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ pub(crate) struct RenderContext<'a> {
pub(crate) externals: &'a [ExternalInfo],
pub(crate) project_path: &'a Path,
pub(crate) schemas_dir: &'a Path,
/// Optional baseline snapshot for delta rendering in export.
pub(crate) baseline: Option<&'a rivet_core::snapshot::Snapshot>,
}

#[allow(dead_code)]
Expand Down
131 changes: 99 additions & 32 deletions rivet-cli/src/render/stats.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;

use rivet_core::coverage;
use rivet_core::document::html_escape;
Expand All @@ -7,6 +8,36 @@ use rivet_core::schema::Severity;
use crate::render::RenderContext;
use crate::render::helpers::badge_for_type;

/// Render a delta badge like `+3` or `-2` with green/red styling.
fn delta_badge(current: usize, baseline: usize) -> String {
let diff = current as isize - baseline as isize;
if diff == 0 {
return String::new();
}
let (sign, color) = if diff > 0 {
("+", "#15713a")
} else {
("", "#c62828")
};
format!(" <span style=\"font-size:.75em;color:{color};font-weight:600\">{sign}{diff}</span>")
}

/// Render a delta badge for percentages.
fn delta_pct_badge(current: f64, baseline: f64) -> String {
let diff = current - baseline;
if diff.abs() < 0.05 {
return String::new();
}
let (sign, color) = if diff > 0.0 {
("+", "#15713a")
} else {
("", "#c62828")
};
format!(
" <span style=\"font-size:.75em;color:{color};font-weight:600\">{sign}{diff:.1}%</span>"
)
}

pub(crate) fn render_stats(ctx: &RenderContext) -> String {
let store = ctx.store;
let graph = ctx.graph;
Expand Down Expand Up @@ -37,46 +68,79 @@ pub(crate) fn render_stats(ctx: &RenderContext) -> String {
ctx.schema.traceability_rules.len(),
);

// Summary cards with colored accents
// Summary cards with colored accents + delta badges
let bl = ctx.baseline;
html.push_str("<div class=\"stat-grid\">");
html.push_str(&format!(
"<div class=\"stat-box stat-blue\"><div class=\"number\">{}</div><div class=\"label\">Artifacts</div></div>",
store.len()
));
html.push_str(&format!(
let _ = write!(
html,
"<div class=\"stat-box stat-blue\"><div class=\"number\">{}{}</div><div class=\"label\">Artifacts</div></div>",
store.len(),
bl.map_or(String::new(), |s| delta_badge(store.len(), s.stats.total)),
);
let _ = write!(
html,
"<div class=\"stat-box stat-green\"><div class=\"number\">{}</div><div class=\"label\">Types</div></div>",
types.len()
));
html.push_str(&format!(
types.len(),
);
let _ = write!(
html,
"<div class=\"stat-box stat-orange\"><div class=\"number\">{}</div><div class=\"label\">Orphans</div></div>",
orphans.len()
));
html.push_str(&format!(
"<div class=\"stat-box stat-red\"><div class=\"number\">{}</div><div class=\"label\">Errors</div></div>",
errors
));
html.push_str(&format!(
"<div class=\"stat-box stat-amber\"><div class=\"number\">{}</div><div class=\"label\">Warnings</div></div>",
warnings
));
html.push_str(&format!(
orphans.len(),
);
let _ = write!(
html,
"<div class=\"stat-box stat-red\"><div class=\"number\">{}{}</div><div class=\"label\">Errors</div></div>",
errors,
bl.map_or(String::new(), |s| delta_badge(errors, s.diagnostics.errors)),
);
let _ = write!(
html,
"<div class=\"stat-box stat-amber\"><div class=\"number\">{}{}</div><div class=\"label\">Warnings</div></div>",
warnings,
bl.map_or(String::new(), |s| delta_badge(
warnings,
s.diagnostics.warnings
)),
);
let _ = write!(
html,
"<div class=\"stat-box stat-purple\"><div class=\"number\">{}</div><div class=\"label\">Broken Links</div></div>",
graph.broken.len()
));
html.push_str(&format!(
graph.broken.len(),
);
let _ = write!(
html,
"<div class=\"stat-box stat-blue\"><div class=\"number\">{}</div><div class=\"label\">Documents</div></div>",
doc_store.len()
));
doc_store.len(),
);
html.push_str("</div>");

// By-type table
html.push_str("<div class=\"card\"><h3>Artifacts by Type</h3><table><thead><tr><th>Type</th><th>Count</th></tr></thead><tbody>");
let has_delta = bl.is_some();
if has_delta {
html.push_str("<div class=\"card\"><h3>Artifacts by Type</h3><table><thead><tr><th>Type</th><th>Count</th><th>Δ</th></tr></thead><tbody>");
} else {
html.push_str("<div class=\"card\"><h3>Artifacts by Type</h3><table><thead><tr><th>Type</th><th>Count</th></tr></thead><tbody>");
}
for t in &types {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td></tr>",
badge_for_type(t),
store.count_by_type(t)
));
let count = store.count_by_type(t);
if has_delta {
let base_count = bl
.and_then(|s| s.stats.by_type.get(*t))
.copied()
.unwrap_or(0);
let _ = write!(
html,
"<tr><td>{}</td><td>{count}</td><td>{}</td></tr>",
badge_for_type(t),
delta_badge(count, base_count),
);
} else {
let _ = write!(
html,
"<tr><td>{}</td><td>{count}</td></tr>",
badge_for_type(t),
);
}
}
html.push_str("</tbody></table></div>");

Expand Down Expand Up @@ -134,11 +198,14 @@ pub(crate) fn render_stats(ctx: &RenderContext) -> String {
};
let total_covered: usize = cov_report.entries.iter().map(|e| e.covered).sum();
let total_items: usize = cov_report.entries.iter().map(|e| e.total).sum();
let cov_delta = bl.map_or(String::new(), |s| {
delta_pct_badge(overall, s.coverage.overall)
});
html.push_str(&format!(
"<div class=\"card\">\
<h3>Traceability Coverage</h3>\
<div style=\"display:flex;align-items:center;gap:1.5rem;margin-bottom:0.75rem\">\
<div style=\"font-size:2rem;font-weight:700;color:{cov_color}\">{overall:.0}%</div>\
<div style=\"font-size:2rem;font-weight:700;color:{cov_color}\">{overall:.0}%{cov_delta}</div>\
<div style=\"flex:1\">\
<div class=\"status-bar-track\" style=\"height:0.6rem\">\
<div class=\"status-bar-fill\" style=\"background:{cov_color};width:{overall:.1}%\"></div>\
Expand Down
1 change: 1 addition & 0 deletions rivet-cli/src/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ impl AppState {
externals: &self.externals,
project_path: &self.project_path_buf,
schemas_dir: &self.schemas_dir,
baseline: None,
}
}
}
Expand Down
Loading