diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e95481..8657502 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 3e9f0ce..ba09228 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -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. @@ -5667,6 +5681,7 @@ fn cmd_lsp(cli: &Cli) -> Result { externals: &externals, project_path: &project_dir, schemas_dir: &schemas_dir, + baseline: None, }; let result = crate::render::render_page(&ctx, page, &view_params); diff --git a/rivet-cli/src/render/coverage.rs b/rivet-cli/src/render/coverage.rs index 765d4fb..d8289f0 100644 --- a/rivet-cli/src/render/coverage.rs +++ b/rivet-cli/src/render/coverage.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; + use rivet_core::coverage; use rivet_core::document::html_escape; @@ -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("

Coverage by Rule

"); - html.push_str(""); + if has_delta { + html.push_str("
RuleSource TypeLinkDirectionCoverageProgress
"); + } else { + html.push_str("
RuleSource TypeLinkDirectionCoverageΔProgress
"); + } for entry in &report.entries { let pct = entry.percentage(); @@ -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 { + "".to_string() + } else { + let (sign, color) = if diff > 0.0 { + ("+", "#15713a") + } else { + ("", "#c62828") + }; + format!("") + } + } else { + String::new() + }; + + let _ = write!( + html, "\ - \ - \ - \ - \ - \ + \ + \ + \ + \ + \ + {delta_cell}\ \ ", - 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("
RuleSource TypeLinkDirectionCoverageProgress
{sign}{diff:.1}%
{}{}{}{}{}/{} ({:.1}%){name}{source}{link}{dir}{covered}/{total} ({pct:.1}%)\
\
\
\
"); diff --git a/rivet-cli/src/render/mod.rs b/rivet-cli/src/render/mod.rs index aa86332..41af23b 100644 --- a/rivet-cli/src/render/mod.rs +++ b/rivet-cli/src/render/mod.rs @@ -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)] diff --git a/rivet-cli/src/render/stats.rs b/rivet-cli/src/render/stats.rs index 6b03cc3..91b2f56 100644 --- a/rivet-cli/src/render/stats.rs +++ b/rivet-cli/src/render/stats.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt::Write as _; use rivet_core::coverage; use rivet_core::document::html_escape; @@ -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!(" {sign}{diff}") +} + +/// 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!( + " {sign}{diff:.1}%" + ) +} + pub(crate) fn render_stats(ctx: &RenderContext) -> String { let store = ctx.store; let graph = ctx.graph; @@ -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("
"); - html.push_str(&format!( - "
{}
Artifacts
", - store.len() - )); - html.push_str(&format!( + let _ = write!( + html, + "
{}{}
Artifacts
", + store.len(), + bl.map_or(String::new(), |s| delta_badge(store.len(), s.stats.total)), + ); + let _ = write!( + html, "
{}
Types
", - types.len() - )); - html.push_str(&format!( + types.len(), + ); + let _ = write!( + html, "
{}
Orphans
", - orphans.len() - )); - html.push_str(&format!( - "
{}
Errors
", - errors - )); - html.push_str(&format!( - "
{}
Warnings
", - warnings - )); - html.push_str(&format!( + orphans.len(), + ); + let _ = write!( + html, + "
{}{}
Errors
", + errors, + bl.map_or(String::new(), |s| delta_badge(errors, s.diagnostics.errors)), + ); + let _ = write!( + html, + "
{}{}
Warnings
", + warnings, + bl.map_or(String::new(), |s| delta_badge( + warnings, + s.diagnostics.warnings + )), + ); + let _ = write!( + html, "
{}
Broken Links
", - graph.broken.len() - )); - html.push_str(&format!( + graph.broken.len(), + ); + let _ = write!( + html, "
{}
Documents
", - doc_store.len() - )); + doc_store.len(), + ); html.push_str("
"); // By-type table - html.push_str("

Artifacts by Type

"); + let has_delta = bl.is_some(); + if has_delta { + html.push_str("

Artifacts by Type

TypeCount
"); + } else { + html.push_str("

Artifacts by Type

TypeCountΔ
"); + } for t in &types { - html.push_str(&format!( - "", - 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, + "", + badge_for_type(t), + delta_badge(count, base_count), + ); + } else { + let _ = write!( + html, + "", + badge_for_type(t), + ); + } } html.push_str("
TypeCount
{}{}
{}{count}{}
{}{count}
"); @@ -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!( "
\

Traceability Coverage

\
\ -
{overall:.0}%
\ +
{overall:.0}%{cov_delta}
\
\
\
\ diff --git a/rivet-cli/src/serve/mod.rs b/rivet-cli/src/serve/mod.rs index e7b2ee6..79055a4 100644 --- a/rivet-cli/src/serve/mod.rs +++ b/rivet-cli/src/serve/mod.rs @@ -208,6 +208,7 @@ impl AppState { externals: &self.externals, project_path: &self.project_path_buf, schemas_dir: &self.schemas_dir, + baseline: None, } } }