From dd4396af5d62c3076eebf9268961c049bf87bfd5 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 18:37:00 -0400 Subject: [PATCH 1/3] feat(export): auto-detect baseline snapshot and render delta columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export auto-detects the most recent snapshot in snapshots/ and renders Δ columns in stats and coverage views: - Summary cards show +N/-N badges for artifacts, errors, warnings - By-type table gains a Δ column with per-type changes - Coverage summary shows Δ percentage - Coverage-by-rule table gains a Δ column with per-rule trend Zero config — just have a snapshot file from a previous release and the delta appears automatically in the next export. --- rivet-cli/src/main.rs | 17 +++- rivet-cli/src/render/coverage.rs | 61 ++++++++++---- rivet-cli/src/render/mod.rs | 2 + rivet-cli/src/render/stats.rs | 131 +++++++++++++++++++++++-------- rivet-cli/src/serve/mod.rs | 1 + 5 files changed, 163 insertions(+), 49 deletions(-) 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, } } } From c6f86be907b9590aefaebc7b1e9f5a0b71671266 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 18:37:49 -0400 Subject: [PATCH 2/3] ci: capture baseline snapshot on release tag push Adds a capture-snapshot job to the release workflow that: 1. Runs rivet snapshot capture --name $TAG on every v* tag push 2. Commits the snapshot JSON to main for future delta comparison 3. Passes the snapshot to the compliance report job via artifact The compliance report (rivet export --format html) will auto-detect the previous snapshot and render delta columns in stats/coverage. --- .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e95481..27f227e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,15 +92,55 @@ 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) ─────────────────────────────────── build-compliance: name: Build compliance report + needs: [capture-snapshot] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - name: Download snapshot + uses: actions/download-artifact@v4 + with: + name: snapshot + path: snapshots/ + - name: Generate compliance report id: report uses: ./.github/actions/compliance From 8edfda2909fd30960590699755770d9302cc999a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Apr 2026 18:49:57 -0400 Subject: [PATCH 3/3] fix(ci): compliance report uses previous snapshot, not current The compliance report should compare against the PREVIOUS release snapshot (already committed to main), not the one just captured for this release. This correctly shows 'what changed since last release' in the exported HTML delta columns. The capture-snapshot job runs in parallel and commits the new snapshot to main for future release comparisons. --- .github/workflows/release.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27f227e..8657502 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,21 +126,16 @@ jobs: 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 - needs: [capture-snapshot] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Download snapshot - uses: actions/download-artifact@v4 - with: - name: snapshot - path: snapshots/ - - name: Generate compliance report id: report uses: ./.github/actions/compliance