From 060f5290331449442eb371c6228e3e433e641e60 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 13:15:44 +0100 Subject: [PATCH 1/7] Add document system, improve graph visualization Documents: - Markdown with YAML frontmatter and [[ID]] wiki-link references - Document model, parser, reference extraction, section hierarchy - DocumentStore, validate_documents() for broken ref detection - /documents and /documents/{id} serve routes with rendered body, table of contents, glossary, and referenced artifacts table - Example SRS document (docs/srs.md) with 16 artifact references - rivet.yaml `docs:` config field Graph improvements: - Full-viewport graph container with zoom controls (+/-/fit) - Clickable nodes navigating to artifact detail via HTMX - Touch support (pinch zoom, drag pan) - Edge labels with background pills for readability - Color legend showing present artifact types - Focus autocomplete with datalist - Wider nodes (200px) and better spacing 75 tests passing. Co-Authored-By: Claude Opus 4.6 --- docs/srs.md | 94 ++++++ etch/src/svg.rs | 25 +- rivet-cli/src/main.rs | 54 +++- rivet-cli/src/serve.rs | 482 +++++++++++++++++++++++++++-- rivet-core/src/document.rs | 614 +++++++++++++++++++++++++++++++++++++ rivet-core/src/lib.rs | 1 + rivet-core/src/model.rs | 5 +- rivet-core/src/validate.rs | 26 ++ rivet.yaml | 3 + 9 files changed, 1264 insertions(+), 40 deletions(-) create mode 100644 docs/srs.md create mode 100644 rivet-core/src/document.rs diff --git a/docs/srs.md b/docs/srs.md new file mode 100644 index 0000000..686a1de --- /dev/null +++ b/docs/srs.md @@ -0,0 +1,94 @@ +--- +id: SRS-001 +type: specification +title: System Requirements Specification +status: draft +glossary: + STPA: Systems-Theoretic Process Analysis + UCA: Unsafe Control Action + ASPICE: Automotive SPICE + OSLC: Open Services for Lifecycle Collaboration + ReqIF: Requirements Interchange Format + WASM: WebAssembly +--- + +# System Requirements Specification + +## 1. Purpose + +This document specifies the system-level requirements for **Rivet**, an SDLC +traceability tool for safety-critical systems. Rivet manages lifecycle +artifacts (requirements, designs, tests, STPA analyses) as version-controlled +YAML files and validates their traceability links against composable schemas. + +## 2. Scope + +Rivet targets Automotive SPICE, ISO 26262, and ISO/SAE 21434 workflows. It +replaces heavyweight ALM tools with a text-file-first, git-friendly approach. + +## 3. Functional Requirements + +### 3.1 Artifact Management + +[[REQ-001]] defines the core principle: artifacts live as human-readable YAML +files under version control. + +[[REQ-002]] extends this to STPA artifacts — losses, hazards, unsafe control +actions, causal factors, and loss scenarios. + +### 3.2 Traceability + +[[REQ-003]] requires full Automotive SPICE V-model traceability, from +stakeholder requirements down to unit verification and back. + +[[REQ-004]] mandates a validation engine that checks link integrity, +cardinality constraints, required fields, and traceability coverage. + +### 3.3 Schema System + +[[REQ-010]] requires schema-driven validation where artifact types, fields, +link types, and traceability rules are defined declaratively. + +[[REQ-015]] aligns schemas with ASPICE 4.0 terminology (verification replaces +test). + +[[REQ-016]] adds cybersecurity schema support for ISO/SAE 21434 and ASPICE +SEC.1-4. + +### 3.4 Interoperability + +[[REQ-005]] covers ReqIF 1.2 import/export for requirements interchange with +tools like DOORS, Polarion, and codebeamer. + +[[REQ-006]] specifies OSLC-based bidirectional synchronization rather than +per-tool REST adapters. + +[[REQ-008]] enables WASM component adapters for custom format plugins. + +### 3.5 User Interface + +[[REQ-007]] requires both a CLI and an HTTP serve pattern for the dashboard. + +### 3.6 Quality + +[[REQ-012]] mandates comprehensive CI quality gates (fmt, clippy, test, miri, +audit, deny, vet, coverage). + +[[REQ-013]] requires performance benchmarks with regression detection. + +[[REQ-014]] structures test artifacts to mirror the ASPICE SWE.4/5/6 levels. + +[[REQ-009]] ties test results to GitHub releases as evidence artifacts. + +[[REQ-011]] pins Rust edition 2024 with MSRV 1.85. + +## 4. Glossary + +| Term | Definition | +|------|-----------| +| STPA | Systems-Theoretic Process Analysis — a hazard analysis method | +| UCA | Unsafe Control Action — a control action that leads to a hazard | +| ASPICE | Automotive SPICE — process assessment model for automotive software | +| OSLC | Open Services for Lifecycle Collaboration — REST-based tool integration | +| ReqIF | Requirements Interchange Format — OMG standard for requirements exchange | +| WASM | WebAssembly — portable binary format for plugin adapters | diff --git a/etch/src/svg.rs b/etch/src/svg.rs index ab9d30f..7700e26 100644 --- a/etch/src/svg.rs +++ b/etch/src/svg.rs @@ -143,10 +143,13 @@ fn write_style(svg: &mut String, options: &SvgOptions) { \x20 .node text {{ font-family: {font}; font-size: {fs}px; \ fill: #222; text-anchor: middle; dominant-baseline: central; }}\n\ \x20 .node .sublabel {{ font-size: {}px; fill: #666; }}\n\ - \x20 .edge path {{ fill: none; stroke: {ec}; stroke-width: 1.2; \ + \x20 .edge path {{ fill: none; stroke: {ec}; stroke-width: 1.4; \ marker-end: url(#arrowhead); }}\n\ + \x20 .edge .label-bg {{ fill: #fff; opacity: 0.85; rx: 3; }}\n\ \x20 .edge text {{ font-family: {font}; font-size: {}px; \ - fill: {ec}; text-anchor: middle; }}\n\ + fill: #555; text-anchor: middle; dominant-baseline: central; \ + font-weight: 500; }}\n\ + \x20 .node:hover rect {{ filter: brightness(0.92); }}\n\ \x20 \n", fs - 2.0, fs - 2.0, @@ -175,15 +178,25 @@ fn write_edges(svg: &mut String, layout: &GraphLayout) { writeln!(svg, " ").unwrap(); - // Edge label at midpoint. + // Edge label at midpoint with background pill. if !edge.label.is_empty() { let mid = edge.points.len() / 2; let (mx, my) = edge.points[mid]; + let label = xml_escape(&edge.label); + let text_y = my - 4.0; + // Approximate label width: ~6.5px per char at default font size. + let approx_w = edge.label.len() as f64 * 6.5 + 8.0; + let approx_h = 14.0; writeln!( svg, - " {}", - my - 4.0, - xml_escape(&edge.label), + " ", + mx - approx_w / 2.0, + text_y - approx_h / 2.0, + ) + .unwrap(); + writeln!( + svg, + " {label}", ) .unwrap(); } diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 33dcae0..52c7276 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -5,6 +5,7 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use rivet_core::diff::{ArtifactDiff, DiagnosticDiff}; +use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; use rivet_core::matrix::{self, Direction}; use rivet_core::schema::Severity; @@ -169,9 +170,9 @@ fn run(cli: Cli) -> Result { Command::Export { format, output } => cmd_export(&cli, format, output.as_deref()), Command::Serve { port } => { let port = *port; - let (store, schema, graph) = load_project(&cli)?; + let (store, schema, graph, doc_store) = load_project_with_docs(&cli)?; let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; - rt.block_on(serve::run(store, schema, graph, port))?; + rt.block_on(serve::run(store, schema, graph, doc_store, port))?; Ok(true) } #[cfg(feature = "wasm")] @@ -257,8 +258,17 @@ fn cmd_stpa( /// Validate a full project (with rivet.yaml). fn cmd_validate(cli: &Cli) -> Result { - let (store, schema, graph) = load_project(cli)?; - let diagnostics = validate::validate(&store, &schema, &graph); + let (store, schema, graph, doc_store) = load_project_with_docs(cli)?; + let mut diagnostics = validate::validate(&store, &schema, &graph); + diagnostics.extend(validate::validate_documents(&doc_store, &store)); + + if !doc_store.is_empty() { + println!( + "Loaded {} documents with {} artifact references", + doc_store.len(), + doc_store.all_references().len() + ); + } print_diagnostics(&diagnostics); @@ -620,6 +630,42 @@ fn load_project(cli: &Cli) -> Result<(Store, rivet_core::schema::Schema, LinkGra Ok((store, schema, graph)) } +fn load_project_with_docs( + cli: &Cli, +) -> Result<(Store, rivet_core::schema::Schema, LinkGraph, DocumentStore)> { + let config_path = cli.project.join("rivet.yaml"); + let config = rivet_core::load_project_config(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + + let schemas_dir = resolve_schemas_dir(cli); + let schema = rivet_core::load_schemas(&config.project.schemas, &schemas_dir) + .context("loading schemas")?; + + let mut store = Store::new(); + for source in &config.sources { + let artifacts = rivet_core::load_artifacts(source, &cli.project) + .with_context(|| format!("loading source '{}'", source.path))?; + for artifact in artifacts { + store.upsert(artifact); + } + } + + let graph = LinkGraph::build(&store, &schema); + + // Load documents from configured directories. + let mut doc_store = DocumentStore::new(); + for docs_path in &config.docs { + let dir = cli.project.join(docs_path); + let docs = document::load_documents(&dir) + .with_context(|| format!("loading docs from '{docs_path}'"))?; + for doc in docs { + doc_store.insert(doc); + } + } + + Ok((store, schema, graph, doc_store)) +} + fn print_stats(store: &Store) { println!("Artifact summary:"); let mut types: Vec<&str> = store.types().collect(); diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index e96a33e..4c82156 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -12,6 +12,7 @@ use petgraph::visit::EdgeRef; use etch::filter::ego_subgraph; use etch::layout::{self as pgv_layout, EdgeInfo, LayoutOptions, NodeInfo}; use etch::svg::{SvgOptions, render_svg}; +use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; use rivet_core::matrix::{self, Direction}; use rivet_core::schema::{Schema, Severity}; @@ -23,14 +24,22 @@ struct AppState { store: Store, schema: Schema, graph: LinkGraph, + doc_store: DocumentStore, } /// Start the axum HTTP server on the given port. -pub async fn run(store: Store, schema: Schema, graph: LinkGraph, port: u16) -> Result<()> { +pub async fn run( + store: Store, + schema: Schema, + graph: LinkGraph, + doc_store: DocumentStore, + port: u16, +) -> Result<()> { let state = Arc::new(AppState { store, schema, graph, + doc_store, }); let app = Router::new() @@ -42,6 +51,8 @@ pub async fn run(store: Store, schema: Schema, graph: LinkGraph, port: u16) -> R .route("/matrix", get(matrix_view)) .route("/graph", get(graph_view)) .route("/stats", get(stats_view)) + .route("/documents", get(documents_list)) + .route("/documents/{id}", get(document_detail)) .with_state(state); let addr = format!("0.0.0.0:{port}"); @@ -118,7 +129,7 @@ nav ul{list-style:none} nav li{margin-bottom:.25rem} nav a{display:block;padding:.45rem .75rem;border-radius:6px;color:#c0c0d0;font-size:.9rem} nav a:hover,nav a.active{background:#2a2a4e;color:#fff;text-decoration:none} -main{flex:1;padding:2rem 2.5rem;max-width:1100px} +main{flex:1;padding:2rem 2.5rem;max-width:1400px} h2{font-size:1.35rem;margin-bottom:1rem;color:#1a1a2e} h3{font-size:1.1rem;margin:1.25rem 0 .5rem;color:#333} table{width:100%;border-collapse:collapse;margin-bottom:1.5rem} @@ -149,12 +160,42 @@ dt{font-weight:600;font-size:.85rem;color:#495057;margin-top:.5rem} dd{margin-left:0;margin-bottom:.25rem} .meta{color:#6c757d;font-size:.85rem} .nav-icon{display:inline-block;width:1.1rem;text-align:center;margin-right:.3rem;font-size:.85rem} -.graph-container{border:1px solid #dee2e6;border-radius:8px;overflow:hidden;background:#fff;cursor:grab} +.graph-container{border:1px solid #dee2e6;border-radius:8px;overflow:hidden;background:#fafbfc;cursor:grab; + height:calc(100vh - 280px);min-height:400px;position:relative} .graph-container:active{cursor:grabbing} -.graph-container svg{display:block;width:100%;height:auto} +.graph-container svg{display:block;width:100%;height:100%;position:absolute;top:0;left:0} +.graph-controls{position:absolute;top:.5rem;right:.5rem;display:flex;flex-direction:column;gap:.25rem;z-index:10} +.graph-controls button{width:32px;height:32px;border:1px solid #ced4da;border-radius:4px; + background:#fff;font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center} +.graph-controls button:hover{background:#e9ecef} +.graph-legend{display:flex;flex-wrap:wrap;gap:.75rem;padding:.5rem 0;font-size:.82rem} +.graph-legend-item{display:flex;align-items:center;gap:.3rem} +.graph-legend-swatch{width:14px;height:14px;border-radius:3px;border:1px solid #0002;flex-shrink:0} .filter-grid{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem} .filter-grid label{font-size:.82rem;display:flex;align-items:center;gap:.25rem} .filter-grid input[type="checkbox"]{margin:0} +.doc-body{line-height:1.75;font-size:.95rem} +.doc-body h1{font-size:1.5rem;margin:1.5rem 0 .75rem;color:#1a1a2e;border-bottom:1px solid #dee2e6;padding-bottom:.3rem} +.doc-body h2{font-size:1.25rem;margin:1.25rem 0 .5rem;color:#333} +.doc-body h3{font-size:1.1rem;margin:1rem 0 .4rem;color:#495057} +.doc-body p{margin:.5rem 0} +.doc-body ul{margin:.5rem 0 .5rem 1.5rem} +.doc-body li{margin:.2rem 0} +.artifact-ref{display:inline-block;padding:.1rem .45rem;border-radius:4px;font-size:.85rem; + font-weight:600;background:#e8f0fe;color:#1a73e8;cursor:pointer;text-decoration:none; + border:1px solid #c6dafc} +.artifact-ref:hover{background:#c6dafc;text-decoration:none} +.artifact-ref.broken{background:#fce8e6;color:#c62828;border-color:#f4c7c3;cursor:default} +.doc-glossary{font-size:.9rem} +.doc-glossary dt{font-weight:600;color:#333} +.doc-glossary dd{margin:0 0 .4rem 1rem;color:#555} +.doc-toc{font-size:.88rem;background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:.75rem 1rem;margin-bottom:1rem} +.doc-toc ul{list-style:none;margin:0;padding:0} +.doc-toc li{margin:.15rem 0} +.doc-toc .toc-h2{padding-left:0} +.doc-toc .toc-h3{padding-left:1rem} +.doc-toc .toc-h4{padding-left:2rem} +.doc-meta{display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem} "#; // ── Pan/zoom JS ────────────────────────────────────────────────────────── @@ -164,6 +205,7 @@ const GRAPH_JS: &str = r#" (function(){ document.addEventListener('htmx:afterSwap', initPanZoom); document.addEventListener('DOMContentLoaded', initPanZoom); + function initPanZoom(){ document.querySelectorAll('.graph-container').forEach(function(c){ if(c._pz) return; @@ -171,8 +213,12 @@ const GRAPH_JS: &str = r#" var svg=c.querySelector('svg'); if(!svg) return; var vb=svg.viewBox.baseVal; + var origVB={x:vb.x, y:vb.y, w:vb.width, h:vb.height}; var drag=false, sx=0, sy=0, ox=0, oy=0; + + // Pan c.addEventListener('mousedown',function(e){ + if(e.target.closest('.graph-controls')) return; drag=true; sx=e.clientX; sy=e.clientY; ox=vb.x; oy=vb.y; e.preventDefault(); }); @@ -184,17 +230,110 @@ const GRAPH_JS: &str = r#" }); c.addEventListener('mouseup',function(){ drag=false; }); c.addEventListener('mouseleave',function(){ drag=false; }); + + // Zoom with wheel c.addEventListener('wheel',function(e){ e.preventDefault(); - var f=e.deltaY>0?1.15:1/1.15; + var f=e.deltaY>0?1.12:1/1.12; var r=c.getBoundingClientRect(); var mx=(e.clientX-r.left)/r.width; var my=(e.clientY-r.top)/r.height; var nx=vb.width*f, ny=vb.height*f; - vb.x+=( vb.width-nx)*mx; + vb.x+=(vb.width-nx)*mx; vb.y+=(vb.height-ny)*my; vb.width=nx; vb.height=ny; },{passive:false}); + + // Touch support + var lastDist=0, lastMid=null; + c.addEventListener('touchstart',function(e){ + if(e.touches.length===1){ + drag=true; sx=e.touches[0].clientX; sy=e.touches[0].clientY; + ox=vb.x; oy=vb.y; + } else if(e.touches.length===2){ + drag=false; + var dx=e.touches[1].clientX-e.touches[0].clientX; + var dy=e.touches[1].clientY-e.touches[0].clientY; + lastDist=Math.sqrt(dx*dx+dy*dy); + lastMid={x:(e.touches[0].clientX+e.touches[1].clientX)/2, + y:(e.touches[0].clientY+e.touches[1].clientY)/2}; + } + },{passive:true}); + c.addEventListener('touchmove',function(e){ + if(e.touches.length===1 && drag){ + e.preventDefault(); + var scale=vb.width/c.clientWidth; + vb.x=ox-(e.touches[0].clientX-sx)*scale; + vb.y=oy-(e.touches[0].clientY-sy)*scale; + } else if(e.touches.length===2){ + e.preventDefault(); + var dx=e.touches[1].clientX-e.touches[0].clientX; + var dy=e.touches[1].clientY-e.touches[0].clientY; + var dist=Math.sqrt(dx*dx+dy*dy); + var f=lastDist/dist; + var r=c.getBoundingClientRect(); + var mid={x:(e.touches[0].clientX+e.touches[1].clientX)/2, + y:(e.touches[0].clientY+e.touches[1].clientY)/2}; + var mx=(mid.x-r.left)/r.width; + var my=(mid.y-r.top)/r.height; + var nx=vb.width*f, ny=vb.height*f; + vb.x+=(vb.width-nx)*mx; + vb.y+=(vb.height-ny)*my; + vb.width=nx; vb.height=ny; + lastDist=dist; lastMid=mid; + } + },{passive:false}); + c.addEventListener('touchend',function(){ drag=false; lastDist=0; }); + + // Zoom buttons + var controls=c.querySelector('.graph-controls'); + if(controls){ + controls.querySelector('.zoom-in').addEventListener('click',function(){ + var cx=vb.x+vb.width/2, cy=vb.y+vb.height/2; + vb.width/=1.3; vb.height/=1.3; + vb.x=cx-vb.width/2; vb.y=cy-vb.height/2; + }); + controls.querySelector('.zoom-out').addEventListener('click',function(){ + var cx=vb.x+vb.width/2, cy=vb.y+vb.height/2; + vb.width*=1.3; vb.height*=1.3; + vb.x=cx-vb.width/2; vb.y=cy-vb.height/2; + }); + controls.querySelector('.zoom-fit').addEventListener('click',function(){ + vb.x=origVB.x; vb.y=origVB.y; vb.width=origVB.w; vb.height=origVB.h; + }); + } + + // Clickable nodes — navigate to artifact detail via htmx + svg.querySelectorAll('.node').forEach(function(node){ + node.style.cursor='pointer'; + node.addEventListener('click',function(e){ + e.stopPropagation(); + var title=node.querySelector('title'); + if(title){ + var id=title.textContent; + htmx.ajax('GET','/artifacts/'+encodeURIComponent(id),'#content'); + } + }); + // Hover effect + node.addEventListener('mouseenter',function(){ + var rect=node.querySelector('rect'); + if(rect) rect.setAttribute('stroke-width','3'); + }); + node.addEventListener('mouseleave',function(){ + var rect=node.querySelector('rect'); + if(rect){ + var isHL=rect.getAttribute('stroke')==='#ff6600'; + rect.setAttribute('stroke-width', isHL?'3':'1.5'); + } + }); + }); + + // Fit to container on first load with some padding + var padding=40; + vb.x=-padding; vb.y=-padding; + vb.width=origVB.w+padding*2; + vb.height=origVB.h+padding*2; + origVB={x:vb.x, y:vb.y, w:vb.width, h:vb.height}; }); } })(); @@ -224,6 +363,7 @@ fn page_layout(content: &str) -> Html {
  • Validation
  • Matrix
  • Graph
  • +
  • Documents
  • @@ -250,6 +390,7 @@ async fn stats_view(State(state): State>) -> Html { fn stats_partial(state: &AppState) -> String { let store = &state.store; let graph = &state.graph; + let doc_store = &state.doc_store; let mut types: Vec<&str> = store.types().collect(); types.sort(); @@ -293,6 +434,12 @@ fn stats_partial(state: &AppState) -> String { "
    {}
    Broken Links
    ", graph.broken.len() )); + if !doc_store.is_empty() { + html.push_str(&format!( + "
    {}
    Documents
    ", + doc_store.len() + )); + } html.push_str(""); // By-type table @@ -528,11 +675,24 @@ async fn graph_view( let colors = type_color_map(); let svg_opts = SvgOptions { - type_colors: colors, + type_colors: colors.clone(), highlight: params.focus.clone().filter(|s| !s.is_empty()), + interactive: true, + base_url: Some("/artifacts".into()), + background: Some("#fafbfc".into()), + font_size: 12.0, + edge_color: "#888".into(), ..SvgOptions::default() }; + let layout_opts = LayoutOptions { + node_width: 200.0, + node_height: 56.0, + rank_separation: 90.0, + node_separation: 30.0, + ..Default::default() + }; + let gl = pgv_layout::layout( &sub, &|_idx, n| { @@ -544,8 +704,8 @@ async fn graph_view( .get(n.as_str()) .map(|a| a.title.clone()) .unwrap_or_default(); - let sublabel = if title.len() > 24 { - Some(format!("{}...", &title[..22])) + let sublabel = if title.len() > 28 { + Some(format!("{}...", &title[..26])) } else if title.is_empty() { None } else { @@ -559,13 +719,23 @@ async fn graph_view( } }, &|_idx, e| EdgeInfo { label: e.clone() }, - &LayoutOptions::default(), + &layout_opts, ); let svg = render_svg(&gl, &svg_opts); + // Collect which types are actually present for the legend + let present_types: std::collections::BTreeSet = sub + .node_indices() + .filter_map(|idx| { + store + .get(sub[idx].as_str()) + .map(|a| a.artifact_type.clone()) + }) + .collect(); + // Build filter controls - let mut html = String::from("

    Graph

    "); + let mut html = String::from("

    Traceability Graph

    "); // Filter form html.push_str("
    "); @@ -598,35 +768,67 @@ async fn graph_view( let focus_val = params.focus.as_deref().unwrap_or(""); html.push_str(&format!( "

    \ -
    ", +
    ", html_escape(focus_val) )); + // Datalist for autocomplete + html.push_str(""); + for a in store.iter() { + html.push_str(&format!(""); + // Depth slider let depth_val = if params.depth > 0 { params.depth } else { 3 }; html.push_str(&format!( - "

    \ -
    " + "

    \ +
    " )); // Link types input let lt_val = params.link_types.as_deref().unwrap_or(""); html.push_str(&format!( "

    \ -
    ", + ", html_escape(lt_val) )); html.push_str("

    "); - html.push_str(""); + html.push_str(""); + + // Legend + if !present_types.is_empty() { + html.push_str("
    "); + for t in &present_types { + let color = colors + .get(t.as_str()) + .map(|s| s.as_str()) + .unwrap_or("#e8e8e8"); + html.push_str(&format!( + "
    {t}
    " + )); + } + html.push_str("
    "); + } + html.push_str(""); - // SVG card - html.push_str("
    "); + // SVG card with zoom controls + html.push_str( + "
    \ +
    \ +
    \ + \ + \ + \ +
    ", + ); html.push_str(&svg); html.push_str("
    "); html.push_str(&format!( - "

    {} nodes, {} edges

    ", + "

    {} nodes, {} edges — scroll to zoom, drag to pan, click nodes to navigate

    ", gl.nodes.len(), gl.edges.len() )); @@ -668,11 +870,24 @@ async fn artifact_graph( let colors = type_color_map(); let svg_opts = SvgOptions { - type_colors: colors, + type_colors: colors.clone(), highlight: Some(id.clone()), + interactive: true, + base_url: Some("/artifacts".into()), + background: Some("#fafbfc".into()), + font_size: 12.0, + edge_color: "#888".into(), ..SvgOptions::default() }; + let layout_opts = LayoutOptions { + node_width: 200.0, + node_height: 56.0, + rank_separation: 90.0, + node_separation: 30.0, + ..Default::default() + }; + let gl = pgv_layout::layout( &sub, &|_idx, n| { @@ -684,8 +899,8 @@ async fn artifact_graph( .get(n.as_str()) .map(|a| a.title.clone()) .unwrap_or_default(); - let sublabel = if title.len() > 24 { - Some(format!("{}...", &title[..22])) + let sublabel = if title.len() > 28 { + Some(format!("{}...", &title[..26])) } else if title.is_empty() { None } else { @@ -699,30 +914,65 @@ async fn artifact_graph( } }, &|_idx, e| EdgeInfo { label: e.clone() }, - &LayoutOptions::default(), + &layout_opts, ); let svg = render_svg(&gl, &svg_opts); + // Collect present types for legend + let present_types: std::collections::BTreeSet = sub + .node_indices() + .filter_map(|idx| { + store + .get(sub[idx].as_str()) + .map(|a| a.artifact_type.clone()) + }) + .collect(); + let mut html = format!("

    Neighborhood of {}

    ", html_escape(&id),); - // Hop control + // Hop control + legend html.push_str("
    "); html.push_str(&format!( "
    \ -

    \ -
    \ +

    \ +
    \

    \ -
    ", + ", id_esc = html_escape(&id), )); + // Legend + if !present_types.is_empty() { + html.push_str("
    "); + for t in &present_types { + let color = colors + .get(t.as_str()) + .map(|s| s.as_str()) + .unwrap_or("#e8e8e8"); + html.push_str(&format!( + "
    {t}
    " + )); + } + html.push_str("
    "); + } + html.push_str("
    "); - html.push_str("
    "); + // SVG with zoom controls + html.push_str( + "
    \ +
    \ +
    \ + \ + \ + \ +
    ", + ); html.push_str(&svg); html.push_str("
    "); html.push_str(&format!( - "

    {} nodes, {} edges ({}-hop neighborhood)

    ", + "

    {} nodes, {} edges ({}-hop neighborhood) — scroll to zoom, drag to pan, click nodes to navigate

    ", gl.nodes.len(), gl.edges.len(), hops @@ -1029,6 +1279,180 @@ async fn matrix_view( Html(html) } +// ── Documents ──────────────────────────────────────────────────────────── + +async fn documents_list(State(state): State>) -> Html { + let doc_store = &state.doc_store; + + let mut html = String::from("

    Documents

    "); + + if doc_store.is_empty() { + html.push_str("

    No documents loaded. Add markdown files with YAML frontmatter to a docs/ directory and reference it in rivet.yaml:

    \ +
    docs:\n  - docs
    "); + return Html(html); + } + + html.push_str( + "", + ); + + for doc in doc_store.iter() { + let status = doc.status.as_deref().unwrap_or("-"); + let status_badge = match status { + "approved" => format!("{status}"), + "draft" => format!("{status}"), + _ => format!("{status}"), + }; + html.push_str(&format!( + "\ + \ + \ + \ + ", + html_escape(&doc.id), + html_escape(&doc.id), + html_escape(&doc.doc_type), + html_escape(&doc.title), + status_badge, + doc.references.len(), + )); + } + + html.push_str("
    IDTypeTitleStatusRefs
    {}{}{}{}{}
    "); + html.push_str(&format!( + "

    {} documents, {} total artifact references

    ", + doc_store.len(), + doc_store.all_references().len() + )); + + Html(html) +} + +async fn document_detail( + State(state): State>, + Path(id): Path, +) -> Html { + let doc_store = &state.doc_store; + let store = &state.store; + + let Some(doc) = doc_store.get(&id) else { + return Html(format!( + "

    Not Found

    Document {} does not exist.

    ", + html_escape(&id) + )); + }; + + let mut html = String::new(); + + // Header with metadata + html.push_str(&format!("

    {}

    ", html_escape(&doc.title))); + + html.push_str("
    "); + html.push_str(&format!( + "{}", + html_escape(&doc.doc_type) + )); + if let Some(status) = &doc.status { + let badge_class = match status.as_str() { + "approved" => "badge-ok", + "draft" => "badge-warn", + _ => "badge-info", + }; + html.push_str(&format!( + "{}", + html_escape(status) + )); + } + html.push_str(&format!( + "{} artifact references", + doc.references.len() + )); + html.push_str("
    "); + + // Table of contents + let toc_sections: Vec<_> = doc.sections.iter().filter(|s| s.level >= 2).collect(); + if toc_sections.len() > 2 { + html.push_str("
    Contents
      "); + for sec in &toc_sections { + let class = match sec.level { + 2 => "toc-h2", + 3 => "toc-h3", + _ => "toc-h4", + }; + let ref_count = if sec.artifact_ids.is_empty() { + String::new() + } else { + format!(" ({})", sec.artifact_ids.len()) + }; + html.push_str(&format!( + "
    • {}{ref_count}
    • ", + html_escape(&sec.title), + )); + } + html.push_str("
    "); + } + + // Rendered body + html.push_str("
    "); + let body_html = document::render_to_html(doc, |aid| store.contains(aid)); + html.push_str(&body_html); + html.push_str("
    "); + + // Glossary + if !doc.glossary.is_empty() { + html.push_str("

    Glossary

    "); + for (term, definition) in &doc.glossary { + html.push_str(&format!( + "
    {}
    {}
    ", + html_escape(term), + html_escape(definition) + )); + } + html.push_str("
    "); + } + + // Referenced artifacts summary + if !doc.references.is_empty() { + html.push_str("

    Referenced Artifacts

    "); + html.push_str(""); + + let mut seen = std::collections::HashSet::new(); + for reference in &doc.references { + if !seen.insert(&reference.artifact_id) { + continue; + } + if let Some(artifact) = store.get(&reference.artifact_id) { + let status = artifact.status.as_deref().unwrap_or("-"); + html.push_str(&format!( + "\ + \ + \ + ", + html_escape(&artifact.id), + html_escape(&artifact.id), + html_escape(&artifact.artifact_type), + html_escape(&artifact.title), + html_escape(status), + )); + } else { + html.push_str(&format!( + "\ + ", + html_escape(&reference.artifact_id), + )); + } + } + + html.push_str("
    IDTypeTitleStatus
    {}{}{}{}
    {}not found
    "); + } + + html.push_str( + "

    ← Back to documents

    ", + ); + + Html(html) +} + // ── Helpers ────────────────────────────────────────────────────────────── fn html_escape(s: &str) -> String { diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs new file mode 100644 index 0000000..38236ed --- /dev/null +++ b/rivet-core/src/document.rs @@ -0,0 +1,614 @@ +//! Document model — markdown files with YAML frontmatter and `[[ID]]` artifact references. +//! +//! Documents represent prose content that surrounds and contextualizes artifacts: +//! specifications, design documents, test plans, glossaries. They complement +//! the structured YAML artifacts with narrative text and hierarchical ordering. +//! +//! ## File format +//! +//! ```markdown +//! --- +//! id: SRS-001 +//! type: specification +//! title: System Requirements Specification +//! status: draft +//! glossary: +//! STPA: Systems-Theoretic Process Analysis +//! --- +//! +//! # System Requirements Specification +//! +//! ## 1. Introduction +//! +//! [[REQ-001]] — Text-file-first artifact management. +//! ``` +//! +//! ## Tool mapping +//! +//! | Concept | ReqIF | OSLC | Polarion | +//! |---------------|------------------|-------------------------|-----------| +//! | Document | SPECIFICATION | RequirementCollection | LiveDoc | +//! | Section | SPEC-HIERARCHY | nested Collection | Heading | +//! | `[[REQ-001]]` | SPEC-OBJECT ref | member link | embedded | + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::error::Error; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// A document loaded from a markdown file with YAML frontmatter. +#[derive(Debug, Clone)] +pub struct Document { + /// Unique document identifier (from frontmatter). + pub id: String, + /// Document type (e.g. "specification", "design", "test-plan"). + pub doc_type: String, + /// Human-readable title. + pub title: String, + /// Lifecycle status. + pub status: Option, + /// Term definitions scoped to this document. + pub glossary: BTreeMap, + /// Raw markdown body (after frontmatter). + pub body: String, + /// Heading-based section hierarchy extracted from the body. + pub sections: Vec
    , + /// All `[[ID]]` references found in the body. + pub references: Vec, + /// Source file path. + pub source_file: Option, +} + +/// A section extracted from markdown headings. +#[derive(Debug, Clone)] +pub struct Section { + /// Heading level (1–6). + pub level: u8, + /// Heading text (without `#` prefix). + pub title: String, + /// Artifact IDs referenced within this section (until the next heading). + pub artifact_ids: Vec, +} + +/// A single `[[ID]]` reference found in the document body. +#[derive(Debug, Clone)] +pub struct DocReference { + /// The artifact ID referenced. + pub artifact_id: String, + /// Line number (1-based) where the reference appears. + pub line: usize, +} + +// --------------------------------------------------------------------------- +// YAML frontmatter model (for serde deserialization) +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize)] +struct Frontmatter { + id: String, + #[serde(rename = "type", default = "default_doc_type")] + doc_type: String, + title: String, + #[serde(default)] + status: Option, + #[serde(default)] + glossary: BTreeMap, +} + +fn default_doc_type() -> String { + "document".into() +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse a markdown file with YAML frontmatter into a [`Document`]. +pub fn parse_document(content: &str, source: Option<&Path>) -> Result { + let (frontmatter, body) = split_frontmatter(content)?; + + let fm: Frontmatter = serde_yaml::from_str(&frontmatter) + .map_err(|e| Error::Schema(format!("document frontmatter: {e}")))?; + + let references = extract_references(&body); + let sections = extract_sections(&body); + + Ok(Document { + id: fm.id, + doc_type: fm.doc_type, + title: fm.title, + status: fm.status, + glossary: fm.glossary, + body, + sections, + references, + source_file: source.map(|p| p.to_path_buf()), + }) +} + +/// Load all `.md` files from a directory as documents. +pub fn load_documents(dir: &Path) -> Result, Error> { + if !dir.is_dir() { + return Ok(Vec::new()); + } + + let mut docs = Vec::new(); + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| Error::Io(format!("{}: {e}", dir.display())))? + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext == "md" || ext == "markdown") + }) + .collect(); + + // Sort for deterministic ordering. + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + let content = std::fs::read_to_string(&path) + .map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + + // Skip files without frontmatter (e.g. plain README.md). + if !content.starts_with("---") { + continue; + } + + match parse_document(&content, Some(&path)) { + Ok(doc) => docs.push(doc), + Err(e) => { + log::warn!("skipping {}: {e}", path.display()); + } + } + } + + Ok(docs) +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/// Split `---\nfrontmatter\n---\nbody` into (frontmatter, body). +fn split_frontmatter(content: &str) -> Result<(String, String), Error> { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return Err(Error::Schema( + "document must start with YAML frontmatter (---)".into(), + )); + } + + // Find the closing `---`. + let after_first = &trimmed[3..]; + let close_pos = after_first + .find("\n---") + .ok_or_else(|| Error::Schema("unterminated frontmatter (missing closing ---)".into()))?; + + let frontmatter = after_first[..close_pos].trim().to_string(); + let body = after_first[close_pos + 4..] + .trim_start_matches('\n') + .to_string(); + + Ok((frontmatter, body)) +} + +/// Extract all `[[ID]]` references from the markdown body. +fn extract_references(body: &str) -> Vec { + let mut refs = Vec::new(); + + for (line_idx, line) in body.lines().enumerate() { + let mut rest = line; + while let Some(start) = rest.find("[[") { + let after = &rest[start + 2..]; + if let Some(end) = after.find("]]") { + let id = after[..end].trim(); + if !id.is_empty() { + refs.push(DocReference { + artifact_id: id.to_string(), + line: line_idx + 1, + }); + } + rest = &after[end + 2..]; + } else { + break; + } + } + } + + refs +} + +/// Extract section hierarchy from markdown headings. +fn extract_sections(body: &str) -> Vec
    { + let mut sections = Vec::new(); + let mut current_refs: Vec = Vec::new(); + + for line in body.lines() { + let trimmed = line.trim_start(); + + if let Some(level) = heading_level(trimmed) { + // If we have a previous section, finalize its references. + if let Some(last) = sections.last_mut() { + let sec: &mut Section = last; + sec.artifact_ids = std::mem::take(&mut current_refs); + } + + let title = trimmed[level as usize..] + .trim_start_matches(' ') + .trim() + .to_string(); + + sections.push(Section { + level, + title, + artifact_ids: Vec::new(), + }); + current_refs.clear(); + } else { + // Collect [[ID]] refs for the current section. + let mut rest = trimmed; + while let Some(start) = rest.find("[[") { + let after = &rest[start + 2..]; + if let Some(end) = after.find("]]") { + let id = after[..end].trim(); + if !id.is_empty() { + current_refs.push(id.to_string()); + } + rest = &after[end + 2..]; + } else { + break; + } + } + } + } + + // Finalize last section. + if let Some(last) = sections.last_mut() { + last.artifact_ids = current_refs; + } + + sections +} + +/// Return the heading level (1–6) if the line starts with `# `. +fn heading_level(line: &str) -> Option { + let hashes = line.bytes().take_while(|&b| b == b'#').count(); + if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') { + Some(hashes as u8) + } else { + None + } +} + +/// Render markdown body to simple HTML, resolving `[[ID]]` into links. +/// +/// This is a lightweight renderer — not a full CommonMark implementation. +/// It handles headings, paragraphs, bold/italic, lists, and `[[ID]]` links. +pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> String { + let mut html = String::with_capacity(doc.body.len() * 2); + let mut in_list = false; + let mut in_paragraph = false; + + for line in doc.body.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if in_list { + html.push_str("\n"); + in_list = false; + } + continue; + } + + // Headings + if let Some(level) = heading_level(trimmed) { + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if in_list { + html.push_str("\n"); + in_list = false; + } + let text = &trimmed[level as usize + 1..]; + let text = resolve_inline(text, &artifact_exists); + html.push_str(&format!("{text}\n")); + continue; + } + + // List items + if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if !in_list { + html.push_str("
      \n"); + in_list = true; + } + let text = resolve_inline(&trimmed[2..], &artifact_exists); + html.push_str(&format!("
    • {text}
    • \n")); + continue; + } + + // Regular text → paragraph + if in_list { + html.push_str("
    \n"); + in_list = false; + } + if !in_paragraph { + html.push_str("

    "); + in_paragraph = true; + } else { + html.push('\n'); + } + html.push_str(&resolve_inline(trimmed, &artifact_exists)); + } + + if in_paragraph { + html.push_str("

    \n"); + } + if in_list { + html.push_str("\n"); + } + + html +} + +/// Resolve inline formatting: `[[ID]]` links, **bold**, *italic*. +fn resolve_inline(text: &str, artifact_exists: &impl Fn(&str) -> bool) -> String { + let mut result = String::with_capacity(text.len() * 2); + let mut chars = text.char_indices().peekable(); + + while let Some((i, ch)) = chars.next() { + if ch == '[' && text[i..].starts_with("[[") { + // Find closing ]] + if let Some(end) = text[i + 2..].find("]]") { + let id = text[i + 2..i + 2 + end].trim(); + if artifact_exists(id) { + result.push_str(&format!( + "{id}" + )); + } else { + result.push_str(&format!("{id}")); + } + // Skip past ]] + let skip_to = i + 2 + end + 2; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } + + if ch == '*' && text[i..].starts_with("**") { + // Bold + if let Some(end) = text[i + 2..].find("**") { + let inner = html_escape(&text[i + 2..i + 2 + end]); + result.push_str(&format!("{inner}")); + let skip_to = i + 2 + end + 2; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } + + if ch == '*' { + // Italic + if let Some(end) = text[i + 1..].find('*') { + let inner = html_escape(&text[i + 1..i + 1 + end]); + result.push_str(&format!("{inner}")); + let skip_to = i + 1 + end + 1; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } + + // Default: escape HTML + match ch { + '&' => result.push_str("&"), + '<' => result.push_str("<"), + '>' => result.push_str(">"), + '"' => result.push_str("""), + _ => result.push(ch), + } + } + + result +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +// --------------------------------------------------------------------------- +// Document store +// --------------------------------------------------------------------------- + +/// In-memory collection of loaded documents. +#[derive(Debug, Default)] +pub struct DocumentStore { + docs: Vec, +} + +impl DocumentStore { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, doc: Document) { + self.docs.push(doc); + } + + pub fn get(&self, id: &str) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + pub fn iter(&self) -> impl Iterator { + self.docs.iter() + } + + pub fn len(&self) -> usize { + self.docs.len() + } + + pub fn is_empty(&self) -> bool { + self.docs.is_empty() + } + + /// All artifact IDs referenced across all documents. + pub fn all_references(&self) -> Vec<&DocReference> { + self.docs.iter().flat_map(|d| &d.references).collect() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_DOC: &str = r#"--- +id: SRS-001 +type: specification +title: System Requirements Specification +status: draft +glossary: + STPA: Systems-Theoretic Process Analysis + UCA: Unsafe Control Action +--- + +# System Requirements Specification + +## 1. Introduction + +This document specifies the system-level requirements. + +## 2. Functional Requirements + +### 2.1 Artifact Management + +[[REQ-001]] — Text-file-first artifact management. + +[[REQ-002]] — STPA artifact support. + +### 2.2 Traceability + +[[REQ-003]] — Full ASPICE V-model traceability. + +## 3. Glossary + +See frontmatter. +"#; + + #[test] + fn parse_frontmatter() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + assert_eq!(doc.id, "SRS-001"); + assert_eq!(doc.doc_type, "specification"); + assert_eq!(doc.title, "System Requirements Specification"); + assert_eq!(doc.status.as_deref(), Some("draft")); + assert_eq!(doc.glossary.len(), 2); + assert_eq!( + doc.glossary.get("STPA").unwrap(), + "Systems-Theoretic Process Analysis" + ); + } + + #[test] + fn extract_references_from_body() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + let ids: Vec<&str> = doc + .references + .iter() + .map(|r| r.artifact_id.as_str()) + .collect(); + assert_eq!(ids, vec!["REQ-001", "REQ-002", "REQ-003"]); + } + + #[test] + fn extract_sections_hierarchy() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + assert_eq!(doc.sections.len(), 6); + assert_eq!(doc.sections[0].level, 1); + assert_eq!(doc.sections[0].title, "System Requirements Specification"); + assert_eq!(doc.sections[1].level, 2); + assert_eq!(doc.sections[1].title, "1. Introduction"); + assert_eq!(doc.sections[2].level, 2); + assert_eq!(doc.sections[2].title, "2. Functional Requirements"); + assert_eq!(doc.sections[3].level, 3); + assert_eq!(doc.sections[3].title, "2.1 Artifact Management"); + assert_eq!(doc.sections[3].artifact_ids, vec!["REQ-001", "REQ-002"]); + assert_eq!(doc.sections[4].level, 3); + assert_eq!(doc.sections[4].title, "2.2 Traceability"); + assert_eq!(doc.sections[4].artifact_ids, vec!["REQ-003"]); + } + + #[test] + fn multiple_refs_on_one_line() { + let content = "---\nid: D-1\ntitle: T\n---\n[[A-1]] and [[B-2]] here\n"; + let doc = parse_document(content, None).unwrap(); + assert_eq!(doc.references.len(), 2); + assert_eq!(doc.references[0].artifact_id, "A-1"); + assert_eq!(doc.references[1].artifact_id, "B-2"); + } + + #[test] + fn missing_frontmatter_is_error() { + let result = parse_document("# Just markdown\n\nNo frontmatter.", None); + assert!(result.is_err()); + } + + #[test] + fn render_html_resolves_refs() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + let html = render_to_html(&doc, |id| id == "REQ-001" || id == "REQ-002"); + assert!(html.contains("artifact-ref")); + assert!(html.contains("hx-get=\"/artifacts/REQ-001\"")); + assert!(html.contains("class=\"artifact-ref broken\"")); + } + + #[test] + fn render_html_headings() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + let html = render_to_html(&doc, |_| true); + assert!(html.contains("

    ")); + assert!(html.contains("

    ")); + assert!(html.contains("

    ")); + } + + #[test] + fn document_store() { + let doc = parse_document(SAMPLE_DOC, None).unwrap(); + let mut store = DocumentStore::new(); + store.insert(doc); + assert_eq!(store.len(), 1); + assert!(store.get("SRS-001").is_some()); + assert_eq!(store.all_references().len(), 3); + } + + #[test] + fn default_doc_type_when_omitted() { + let content = "---\nid: D-1\ntitle: Test\n---\nBody.\n"; + let doc = parse_document(content, None).unwrap(); + assert_eq!(doc.doc_type, "document"); + } +} diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 450f4d7..066e718 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod adapter; pub mod diff; +pub mod document; pub mod error; pub mod formats; pub mod links; diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 7d86530..2d980ec 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -76,12 +76,15 @@ impl Artifact { } } -/// Project configuration loaded from `trace.yaml`. +/// Project configuration loaded from `rivet.yaml`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectConfig { pub project: ProjectMetadata, #[serde(default)] pub sources: Vec, + /// Directories containing markdown documents (with YAML frontmatter). + #[serde(default)] + pub docs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rivet-core/src/validate.rs b/rivet-core/src/validate.rs index e64d882..57f9c2e 100644 --- a/rivet-core/src/validate.rs +++ b/rivet-core/src/validate.rs @@ -1,3 +1,4 @@ +use crate::document::DocumentStore; use crate::links::LinkGraph; use crate::schema::{Cardinality, Schema, Severity}; use crate::store::Store; @@ -224,3 +225,28 @@ pub fn validate(store: &Store, schema: &Schema, graph: &LinkGraph) -> Vec Vec { + let mut diagnostics = Vec::new(); + + for doc in doc_store.iter() { + for reference in &doc.references { + if !store.contains(&reference.artifact_id) { + diagnostics.push(Diagnostic { + severity: Severity::Warning, + artifact_id: Some(doc.id.clone()), + rule: "doc-broken-ref".into(), + message: format!( + "document references [[{}]] (line {}) which does not exist", + reference.artifact_id, reference.line + ), + }); + } + } + } + + diagnostics +} diff --git a/rivet.yaml b/rivet.yaml index 675cb58..895fbd8 100644 --- a/rivet.yaml +++ b/rivet.yaml @@ -8,3 +8,6 @@ project: sources: - path: artifacts format: generic-yaml + +docs: + - docs From ba4c924378f7a11eca673b4b117460e054664c04 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 13:20:47 +0100 Subject: [PATCH 2/7] Redesign dashboard UI with Atkinson Hyperlegible and design system - Atkinson Hyperlegible for body text, JetBrains Mono for IDs/code - CSS custom properties design system (colors, spacing, shadows) - Dark sidebar (#0f0f13) with active nav state tracking - HTMX loading bar animation and content swap transitions - Refined tables (alternating rows, hover), cards (subtle shadows), badges (consistent palette), form controls (focus rings, styled selects) - Stat boxes with hover lift, scrollbar styling, selection color - Keyboard navigation focus-visible outlines throughout Co-Authored-By: Claude Opus 4.6 --- rivet-cli/src/serve.rs | 317 +++++++++++++++++++++++++++++++---------- 1 file changed, 244 insertions(+), 73 deletions(-) diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index 4c82156..68dee8b 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -117,85 +117,216 @@ fn type_color_map() -> HashMap { // ── CSS ────────────────────────────────────────────────────────────────── const CSS: &str = r#" -*{box-sizing:border-box;margin:0;padding:0} -body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; - color:#1a1a2e;background:#f8f9fa;line-height:1.6} -a{color:#3a86ff;text-decoration:none} -a:hover{text-decoration:underline} +/* ── Reset & base ─────────────────────────────────────────────── */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg: #f5f5f7; + --surface:#fff; + --sidebar:#0f0f13; + --sidebar-hover:#1c1c24; + --sidebar-text:#9898a6; + --sidebar-active:#fff; + --text: #1d1d1f; + --text-secondary:#6e6e73; + --border: #e5e5ea; + --accent: #3a86ff; + --accent-hover:#2568d6; + --radius: 10px; + --radius-sm:6px; + --shadow: 0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04); + --shadow-md:0 4px 12px rgba(0,0,0,.06),0 1px 3px rgba(0,0,0,.04); + --mono: 'JetBrains Mono','Fira Code','SF Mono',Menlo,monospace; + --font: 'Atkinson Hyperlegible',system-ui,-apple-system,sans-serif; + --transition:180ms ease; +} +html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility} +body{font-family:var(--font);color:var(--text);background:var(--bg);line-height:1.6;font-size:15px} + +/* ── Links ────────────────────────────────────────────────────── */ +a{color:var(--accent);text-decoration:none;transition:color var(--transition)} +a:hover{color:var(--accent-hover)} +a:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px} + +/* ── Shell layout ─────────────────────────────────────────────── */ .shell{display:flex;min-height:100vh} -nav{width:220px;background:#1a1a2e;color:#e0e0e0;padding:1.5rem 1rem;flex-shrink:0} -nav h1{font-size:1.2rem;color:#fff;margin-bottom:1.5rem;letter-spacing:.05em} -nav ul{list-style:none} -nav li{margin-bottom:.25rem} -nav a{display:block;padding:.45rem .75rem;border-radius:6px;color:#c0c0d0;font-size:.9rem} -nav a:hover,nav a.active{background:#2a2a4e;color:#fff;text-decoration:none} -main{flex:1;padding:2rem 2.5rem;max-width:1400px} -h2{font-size:1.35rem;margin-bottom:1rem;color:#1a1a2e} -h3{font-size:1.1rem;margin:1.25rem 0 .5rem;color:#333} -table{width:100%;border-collapse:collapse;margin-bottom:1.5rem} -th,td{text-align:left;padding:.5rem .75rem;border-bottom:1px solid #dee2e6} -th{background:#e9ecef;font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.03em;color:#495057} -td{font-size:.9rem} -tr:hover td{background:#f1f3f5} -.badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.78rem;font-weight:600} -.badge-error{background:#ffe0e0;color:#c62828} -.badge-warn{background:#fff3cd;color:#856404} -.badge-info{background:#d1ecf1;color:#0c5460} -.badge-ok{background:#d4edda;color:#155724} -.badge-type{background:#e8e0f0;color:#4a148c} -.card{background:#fff;border:1px solid #dee2e6;border-radius:8px;padding:1.25rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.04)} -.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem} -.stat-box{background:#fff;border:1px solid #dee2e6;border-radius:8px;padding:1rem;text-align:center} -.stat-box .number{font-size:2rem;font-weight:700;color:#3a86ff} -.stat-box .label{font-size:.85rem;color:#6c757d} -.link-pill{display:inline-block;padding:.1rem .4rem;border-radius:3px;font-size:.8rem;background:#e9ecef;margin:.1rem} -.form-row{display:flex;gap:.75rem;align-items:end;flex-wrap:wrap;margin-bottom:1rem} -.form-row label{font-size:.85rem;font-weight:600;color:#495057} -.form-row select,.form-row input{padding:.4rem .6rem;border:1px solid #ced4da;border-radius:4px;font-size:.9rem} -.form-row button{padding:.4rem 1rem;background:#3a86ff;color:#fff;border:none;border-radius:4px; - font-size:.9rem;cursor:pointer} -.form-row button:hover{background:#2a6fdf} -dl{margin:.5rem 0} -dt{font-weight:600;font-size:.85rem;color:#495057;margin-top:.5rem} -dd{margin-left:0;margin-bottom:.25rem} -.meta{color:#6c757d;font-size:.85rem} -.nav-icon{display:inline-block;width:1.1rem;text-align:center;margin-right:.3rem;font-size:.85rem} -.graph-container{border:1px solid #dee2e6;border-radius:8px;overflow:hidden;background:#fafbfc;cursor:grab; - height:calc(100vh - 280px);min-height:400px;position:relative} + +/* ── Sidebar navigation ──────────────────────────────────────── */ +nav{width:232px;background:var(--sidebar);color:var(--sidebar-text); + padding:1.75rem 1rem;flex-shrink:0;display:flex;flex-direction:column; + position:sticky;top:0;height:100vh;overflow-y:auto; + border-right:1px solid rgba(255,255,255,.06)} +nav h1{font-size:1.05rem;font-weight:700;color:var(--sidebar-active); + margin-bottom:2rem;letter-spacing:.04em;padding:0 .75rem; + display:flex;align-items:center;gap:.5rem} +nav h1::before{content:'';display:inline-block;width:8px;height:8px; + border-radius:50%;background:var(--accent);flex-shrink:0} +nav ul{list-style:none;display:flex;flex-direction:column;gap:2px} +nav li{margin:0} +nav a{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-radius:var(--radius-sm); + color:var(--sidebar-text);font-size:.875rem;font-weight:500; + transition:all var(--transition)} +nav a:hover{background:var(--sidebar-hover);color:var(--sidebar-active);text-decoration:none} +nav a.active{background:var(--sidebar-hover);color:var(--sidebar-active)} +nav a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} + +/* ── Main content ─────────────────────────────────────────────── */ +main{flex:1;padding:2.5rem 3rem;max-width:1400px;min-width:0} +main.htmx-swapping{opacity:.4;transition:opacity 150ms ease-out} +main.htmx-settling{opacity:1;transition:opacity 200ms ease-in} + +/* ── Loading bar ──────────────────────────────────────────────── */ +#loading-bar{position:fixed;top:0;left:0;width:0;height:2px;background:var(--accent); + z-index:9999;transition:none;pointer-events:none} +#loading-bar.active{width:85%;transition:width 8s cubic-bezier(.1,.05,.1,1)} +#loading-bar.done{width:100%;transition:width 100ms ease;opacity:0;transition:width 100ms ease,opacity 300ms ease 100ms} + +/* ── Typography ───────────────────────────────────────────────── */ +h2{font-size:1.4rem;font-weight:700;margin-bottom:1.25rem;color:var(--text);letter-spacing:-.01em} +h3{font-size:1.05rem;font-weight:600;margin:1.5rem 0 .75rem;color:var(--text)} +code,pre{font-family:var(--mono);font-size:.85em} +pre{background:#f1f1f3;padding:1rem;border-radius:var(--radius-sm);overflow-x:auto} + +/* ── Tables ───────────────────────────────────────────────────── */ +table{width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem} +th,td{text-align:left;padding:.65rem .875rem} +th{font-weight:600;font-size:.75rem;text-transform:uppercase;letter-spacing:.06em; + color:var(--text-secondary);border-bottom:2px solid var(--border);background:transparent} +td{border-bottom:1px solid var(--border)} +tbody tr{transition:background var(--transition)} +tbody tr:nth-child(even){background:rgba(0,0,0,.015)} +tbody tr:hover{background:rgba(58,134,255,.04)} +td a{font-family:var(--mono);font-size:.85rem;font-weight:500} + +/* ── Badges ───────────────────────────────────────────────────── */ +.badge{display:inline-flex;align-items:center;padding:.2rem .55rem;border-radius:5px; + font-size:.73rem;font-weight:600;letter-spacing:.02em;line-height:1.4;white-space:nowrap} +.badge-error{background:#fee;color:#c62828} +.badge-warn{background:#fff8e1;color:#8b6914} +.badge-info{background:#e8f4fd;color:#0c5a82} +.badge-ok{background:#e6f9ed;color:#15713a} +.badge-type{background:#f0ecf9;color:#5b2d9e;font-family:var(--mono);font-size:.72rem} + +/* ── Cards ────────────────────────────────────────────────────── */ +.card{background:var(--surface);border-radius:var(--radius);padding:1.5rem; + margin-bottom:1.25rem;box-shadow:var(--shadow);border:1px solid var(--border); + transition:box-shadow var(--transition)} + +/* ── Stat grid ────────────────────────────────────────────────── */ +.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem;margin-bottom:1.75rem} +.stat-box{background:var(--surface);border-radius:var(--radius);padding:1.25rem 1rem;text-align:center; + box-shadow:var(--shadow);border:1px solid var(--border);transition:box-shadow var(--transition),transform var(--transition)} +.stat-box:hover{box-shadow:var(--shadow-md);transform:translateY(-1px)} +.stat-box .number{font-size:2rem;font-weight:800;color:var(--accent);letter-spacing:-.02em; + font-variant-numeric:tabular-nums;line-height:1.2} +.stat-box .label{font-size:.8rem;font-weight:500;color:var(--text-secondary);margin-top:.25rem; + text-transform:uppercase;letter-spacing:.04em} + +/* ── Link pills ───────────────────────────────────────────────── */ +.link-pill{display:inline-block;padding:.15rem .45rem;border-radius:4px; + font-size:.76rem;font-family:var(--mono);background:#f0f0f3; + color:var(--text-secondary);margin:.1rem;font-weight:500} + +/* ── Forms ────────────────────────────────────────────────────── */ +.form-row{display:flex;gap:1rem;align-items:end;flex-wrap:wrap;margin-bottom:1rem} +.form-row label{font-size:.8rem;font-weight:600;color:var(--text-secondary); + text-transform:uppercase;letter-spacing:.04em} +.form-row select,.form-row input[type="text"],.form-row input[type="search"], +.form-row input:not([type]),.form-row input[list]{ + padding:.5rem .75rem;border:1px solid var(--border);border-radius:var(--radius-sm); + font-size:.875rem;font-family:var(--font);background:var(--surface);color:var(--text); + transition:border-color var(--transition),box-shadow var(--transition);appearance:none; + -webkit-appearance:none} +.form-row select{padding-right:2rem;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%236e6e73' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat:no-repeat;background-position:right .75rem center} +.form-row input:focus,.form-row select:focus{ + outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(58,134,255,.15)} +.form-row input[type="range"]{padding:0;border:none;accent-color:var(--accent);width:100%} +.form-row input[type="range"]:focus{box-shadow:none} +.form-row button{padding:.5rem 1.25rem;background:var(--accent);color:#fff;border:none; + border-radius:var(--radius-sm);font-size:.875rem;font-weight:600; + font-family:var(--font);cursor:pointer;transition:all var(--transition); + box-shadow:0 1px 2px rgba(0,0,0,.08)} +.form-row button:hover{background:var(--accent-hover);box-shadow:0 2px 6px rgba(58,134,255,.25);transform:translateY(-1px)} +.form-row button:active{transform:translateY(0)} +.form-row button:focus-visible{outline:2px solid var(--accent);outline-offset:2px} + +/* ── Definition lists ─────────────────────────────────────────── */ +dl{margin:.75rem 0} +dt{font-weight:600;font-size:.8rem;color:var(--text-secondary);margin-top:.75rem; + text-transform:uppercase;letter-spacing:.04em} +dd{margin-left:0;margin-bottom:.25rem;margin-top:.2rem} + +/* ── Meta text ────────────────────────────────────────────────── */ +.meta{color:var(--text-secondary);font-size:.85rem} + +/* ── Nav icons ────────────────────────────────────────────────── */ +.nav-icon{display:inline-flex;width:1.25rem;justify-content:center;flex-shrink:0;font-size:.8rem;opacity:.5} +nav a:hover .nav-icon,nav a.active .nav-icon{opacity:.9} + +/* ── Graph ────────────────────────────────────────────────────── */ +.graph-container{border-radius:var(--radius);overflow:hidden;background:#fafbfc;cursor:grab; + height:calc(100vh - 280px);min-height:400px;position:relative;border:1px solid var(--border)} .graph-container:active{cursor:grabbing} .graph-container svg{display:block;width:100%;height:100%;position:absolute;top:0;left:0} -.graph-controls{position:absolute;top:.5rem;right:.5rem;display:flex;flex-direction:column;gap:.25rem;z-index:10} -.graph-controls button{width:32px;height:32px;border:1px solid #ced4da;border-radius:4px; - background:#fff;font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center} -.graph-controls button:hover{background:#e9ecef} -.graph-legend{display:flex;flex-wrap:wrap;gap:.75rem;padding:.5rem 0;font-size:.82rem} -.graph-legend-item{display:flex;align-items:center;gap:.3rem} -.graph-legend-swatch{width:14px;height:14px;border-radius:3px;border:1px solid #0002;flex-shrink:0} -.filter-grid{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem} -.filter-grid label{font-size:.82rem;display:flex;align-items:center;gap:.25rem} -.filter-grid input[type="checkbox"]{margin:0} -.doc-body{line-height:1.75;font-size:.95rem} -.doc-body h1{font-size:1.5rem;margin:1.5rem 0 .75rem;color:#1a1a2e;border-bottom:1px solid #dee2e6;padding-bottom:.3rem} -.doc-body h2{font-size:1.25rem;margin:1.25rem 0 .5rem;color:#333} -.doc-body h3{font-size:1.1rem;margin:1rem 0 .4rem;color:#495057} +.graph-controls{position:absolute;top:.75rem;right:.75rem;display:flex;flex-direction:column;gap:.35rem;z-index:10} +.graph-controls button{width:34px;height:34px;border:1px solid var(--border);border-radius:var(--radius-sm); + background:var(--surface);font-size:1rem;cursor:pointer;display:flex;align-items:center; + justify-content:center;box-shadow:var(--shadow);color:var(--text); + transition:all var(--transition)} +.graph-controls button:hover{background:#f0f0f3;box-shadow:var(--shadow-md)} +.graph-controls button:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.graph-legend{display:flex;flex-wrap:wrap;gap:.75rem;padding:.75rem 0 .25rem;font-size:.82rem} +.graph-legend-item{display:flex;align-items:center;gap:.35rem;color:var(--text-secondary)} +.graph-legend-swatch{width:12px;height:12px;border-radius:3px;flex-shrink:0} + +/* ── Filter grid ──────────────────────────────────────────────── */ +.filter-grid{display:flex;flex-wrap:wrap;gap:.6rem;margin-bottom:.75rem} +.filter-grid label{font-size:.8rem;display:flex;align-items:center;gap:.3rem; + color:var(--text-secondary);cursor:pointer;padding:.2rem .45rem; + border-radius:4px;transition:background var(--transition); + text-transform:none;letter-spacing:0;font-weight:500} +.filter-grid label:hover{background:rgba(58,134,255,.06)} +.filter-grid input[type="checkbox"]{margin:0;accent-color:var(--accent);width:14px;height:14px; + cursor:pointer;border-radius:3px} + +/* ── Document styles ──────────────────────────────────────────── */ +.doc-body{line-height:1.8;font-size:.95rem} +.doc-body h1{font-size:1.4rem;font-weight:700;margin:2rem 0 .75rem;color:var(--text); + border-bottom:2px solid var(--border);padding-bottom:.5rem} +.doc-body h2{font-size:1.2rem;font-weight:600;margin:1.5rem 0 .5rem;color:var(--text)} +.doc-body h3{font-size:1.05rem;font-weight:600;margin:1.25rem 0 .4rem;color:var(--text-secondary)} .doc-body p{margin:.5rem 0} .doc-body ul{margin:.5rem 0 .5rem 1.5rem} .doc-body li{margin:.2rem 0} -.artifact-ref{display:inline-block;padding:.1rem .45rem;border-radius:4px;font-size:.85rem; - font-weight:600;background:#e8f0fe;color:#1a73e8;cursor:pointer;text-decoration:none; - border:1px solid #c6dafc} -.artifact-ref:hover{background:#c6dafc;text-decoration:none} -.artifact-ref.broken{background:#fce8e6;color:#c62828;border-color:#f4c7c3;cursor:default} +.artifact-ref{display:inline-flex;align-items:center;padding:.15rem .5rem;border-radius:5px; + font-size:.8rem;font-weight:600;font-family:var(--mono);background:#edf2ff; + color:#3a63c7;cursor:pointer;text-decoration:none; + border:1px solid #d4def5;transition:all var(--transition)} +.artifact-ref:hover{background:#d4def5;text-decoration:none;transform:translateY(-1px);box-shadow:0 2px 4px rgba(0,0,0,.06)} +.artifact-ref.broken{background:#fde8e8;color:#c62828;border-color:#f4c7c3;cursor:default} +.artifact-ref.broken:hover{transform:none;box-shadow:none} .doc-glossary{font-size:.9rem} -.doc-glossary dt{font-weight:600;color:#333} -.doc-glossary dd{margin:0 0 .4rem 1rem;color:#555} -.doc-toc{font-size:.88rem;background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:.75rem 1rem;margin-bottom:1rem} -.doc-toc ul{list-style:none;margin:0;padding:0} -.doc-toc li{margin:.15rem 0} +.doc-glossary dt{font-weight:600;color:var(--text)} +.doc-glossary dd{margin:0 0 .5rem 1rem;color:var(--text-secondary)} +.doc-toc{font-size:.88rem;background:var(--surface);border:1px solid var(--border); + border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1.25rem; + box-shadow:var(--shadow)} +.doc-toc strong{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary)} +.doc-toc ul{list-style:none;margin:.5rem 0 0;padding:0} +.doc-toc li{margin:.2rem 0;color:var(--text-secondary)} .doc-toc .toc-h2{padding-left:0} -.doc-toc .toc-h3{padding-left:1rem} -.doc-toc .toc-h4{padding-left:2rem} -.doc-meta{display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem} +.doc-toc .toc-h3{padding-left:1.25rem} +.doc-toc .toc-h4{padding-left:2.5rem} +.doc-meta{display:flex;gap:.75rem;flex-wrap:wrap;align-items:center;margin-bottom:1.25rem} + +/* ── Scrollbar (subtle) ───────────────────────────────────────── */ +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:#c5c5cd;border-radius:3px} +::-webkit-scrollbar-thumb:hover{background:#a0a0aa} + +/* ── Selection ────────────────────────────────────────────────── */ +::selection{background:rgba(58,134,255,.18)} "#; // ── Pan/zoom JS ────────────────────────────────────────────────────────── @@ -203,6 +334,42 @@ dd{margin-left:0;margin-bottom:.25rem} const GRAPH_JS: &str = r#" +
    -
    +
    {content}
    From 739c30dab94855662e27cb415f23ae468f496758 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 13:23:00 +0100 Subject: [PATCH 3/7] Fix glossary rendering: add markdown table support, use frontmatter glossary The document body had a duplicate glossary as a markdown table that wasn't being parsed. Added table rendering (header, separator, body rows) to the lightweight markdown renderer and replaced the body table with a reference to the frontmatter glossary panel. Co-Authored-By: Claude Opus 4.6 --- docs/srs.md | 9 +---- rivet-core/src/document.rs | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/srs.md b/docs/srs.md index 686a1de..3b12129 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -84,11 +84,4 @@ audit, deny, vet, coverage). ## 4. Glossary -| Term | Definition | -|------|-----------| -| STPA | Systems-Theoretic Process Analysis — a hazard analysis method | -| UCA | Unsafe Control Action — a control action that leads to a hazard | -| ASPICE | Automotive SPICE — process assessment model for automotive software | -| OSLC | Open Services for Lifecycle Collaboration — REST-based tool integration | -| ReqIF | Requirements Interchange Format — OMG standard for requirements exchange | -| WASM | WebAssembly — portable binary format for plugin adapters | +See the glossary panel below (defined in document frontmatter). diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index 38236ed..b0d173b 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -294,6 +294,8 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> let mut html = String::with_capacity(doc.body.len() * 2); let mut in_list = false; let mut in_paragraph = false; + let mut in_table = false; + let mut table_header_done = false; for line in doc.body.lines() { let trimmed = line.trim(); @@ -307,6 +309,11 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("\n"); in_list = false; } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } continue; } @@ -320,18 +327,71 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("\n"); in_list = false; } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } let text = &trimmed[level as usize + 1..]; let text = resolve_inline(text, &artifact_exists); html.push_str(&format!("{text}\n")); continue; } + // Table rows (lines starting and ending with |) + if trimmed.starts_with('|') && trimmed.ends_with('|') { + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if in_list { + html.push_str("\n"); + in_list = false; + } + + // Skip separator rows like |---|---| + if is_table_separator(trimmed) { + continue; + } + + let cells: Vec<&str> = trimmed + .trim_matches('|') + .split('|') + .map(|c| c.trim()) + .collect(); + + if !in_table { + // First row is the header + html.push_str(""); + for cell in &cells { + let text = resolve_inline(cell, &artifact_exists); + html.push_str(&format!("")); + } + html.push_str("\n"); + in_table = true; + table_header_done = true; + } else if table_header_done { + html.push_str(""); + for cell in &cells { + let text = resolve_inline(cell, &artifact_exists); + html.push_str(&format!("")); + } + html.push_str("\n"); + } + continue; + } + // List items if trimmed.starts_with("- ") || trimmed.starts_with("* ") { if in_paragraph { html.push_str("

    \n"); in_paragraph = false; } + if in_table { + html.push_str("
    {text}
    {text}
    \n"); + in_table = false; + table_header_done = false; + } if !in_list { html.push_str("
      \n"); in_list = true; @@ -346,6 +406,11 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("
    \n"); in_list = false; } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } if !in_paragraph { html.push_str("

    "); in_paragraph = true; @@ -361,10 +426,20 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> if in_list { html.push_str("\n"); } + if in_table { + html.push_str("\n"); + } html } +/// Check if a table row is a separator (e.g. `|---|---|`). +fn is_table_separator(line: &str) -> bool { + line.trim_matches('|') + .split('|') + .all(|cell| cell.trim().chars().all(|c| c == '-' || c == ':')) +} + /// Resolve inline formatting: `[[ID]]` links, **bold**, *italic*. fn resolve_inline(text: &str, artifact_exists: &impl Fn(&str) -> bool) -> String { let mut result = String::with_capacity(text.len() * 2); From 6e5e9700a5154bd1935e45cdc5f81b7e40751154 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 13:43:10 +0100 Subject: [PATCH 4/7] Add coverage reporting, init scaffold, search, ASPICE example, and UI polish - Coverage: rivet-core/src/coverage.rs module with compute_coverage(), CLI `rivet coverage` command (table/json output, --fail-under), /coverage dashboard route with progress bars and uncovered artifact lists - Init: `rivet init` command creates rivet.yaml + artifacts/ + docs/ scaffold with --name, --schema, --dir options - Search: Cmd+K command palette with debounced search across artifacts and documents, arrow key navigation, highlighted matches, grouped results - ASPICE example: examples/aspice/ with full V-model braking system (STKH->SYSREQ->SWREQ->SWARCH->SWDD->UVER->SWINTVER->SWVER->SYSINTVER->SYSVER) - UI: Atkinson Hyperlegible font, dark sidebar, loading bar, content transitions, refined tables/badges/forms, command palette styling Co-Authored-By: Claude Opus 4.6 --- examples/aspice/artifacts/architecture.yaml | 176 +++++++ examples/aspice/artifacts/requirements.yaml | 156 ++++++ examples/aspice/artifacts/verification.yaml | 316 ++++++++++++ examples/aspice/docs/sdd.md | 112 ++++ examples/aspice/rivet.yaml | 14 + rivet-cli/src/main.rs | 212 ++++++++ rivet-cli/src/serve.rs | 541 ++++++++++++++++++++ rivet-core/src/coverage.rs | 317 ++++++++++++ rivet-core/src/lib.rs | 1 + 9 files changed, 1845 insertions(+) create mode 100644 examples/aspice/artifacts/architecture.yaml create mode 100644 examples/aspice/artifacts/requirements.yaml create mode 100644 examples/aspice/artifacts/verification.yaml create mode 100644 examples/aspice/docs/sdd.md create mode 100644 examples/aspice/rivet.yaml create mode 100644 rivet-core/src/coverage.rs diff --git a/examples/aspice/artifacts/architecture.yaml b/examples/aspice/artifacts/architecture.yaml new file mode 100644 index 0000000..110cc90 --- /dev/null +++ b/examples/aspice/artifacts/architecture.yaml @@ -0,0 +1,176 @@ +artifacts: + # ── System Architecture (SYS.3) ────────────────────────────────────── + + - id: SYSARCH-1 + type: system-arch-component + title: Hydraulic Control Unit + status: approved + description: > + The HCU receives brake pressure commands from the ECU and drives + proportional solenoid valves to modulate brake line pressure + independently on each axle. Contains the valve block, pump motor, + and pressure sensors. + tags: [braking, hcu, hardware] + fields: + component-type: mixed + interfaces: + provided: + - name: pressure-command + protocol: CAN FD + description: Accepts 12-bit pressure demand per axle at 100 Hz + required: + - name: power-supply + description: 12 V nominal, 60 A peak during pump operation + links: + - type: allocated-from + target: SYSREQ-1 + - type: allocated-from + target: SYSREQ-2 + + - id: SYSARCH-2 + type: system-arch-component + title: ABS Electronic Control Unit + status: approved + description: > + The ABS ECU hosts the slip control software, reads wheel speed sensors + via the sensor interface, and commands pressure modulation through the + HCU. Includes the microcontroller, CAN FD transceiver, and power + management. + tags: [braking, abs, ecu] + fields: + component-type: mixed + interfaces: + provided: + - name: abs-status + protocol: CAN FD + description: ABS active flag, wheel speeds, slip ratios at 100 Hz + required: + - name: wheel-speed-input + protocol: analog + description: 4x wheel speed sensor signals (inductive, 48 teeth) + - name: hcu-command + protocol: CAN FD + description: Pressure build/hold/release commands to HCU + links: + - type: allocated-from + target: SYSREQ-3 + + # ── Software Architecture (SWE.2) ──────────────────────────────────── + + - id: SWARCH-1 + type: sw-arch-component + title: Brake Pressure Manager + status: approved + description: > + Software component responsible for computing brake pressure demands + for each axle. Reads pedal position and axle load estimates, applies + the load-dependent ratio, and outputs DAC commands to the HCU valve + driver. Runs in the 10 ms periodic task. + tags: [braking, ebd, software] + fields: + interfaces: + provided: + - name: pressure_demand_output + type: function + description: "fn pressure_demand(pedal: u16, speed: u16, ratio: f32) -> [u16; 2]" + required: + - name: axle_load_input + type: function + description: "fn get_axle_loads() -> (f32, f32)" + concurrency: single-threaded (10 ms cyclic task) + resource-budgets: + stack: 2 KiB + wcet: 200 us + links: + - type: allocated-from + target: SWREQ-1 + - type: allocated-from + target: SWREQ-2 + + - id: SWARCH-2 + type: sw-arch-component + title: ABS Slip Controller + status: approved + description: > + Software component implementing the wheel slip regulation algorithm. + Reads wheel speed sensor inputs at 500 Hz via the sensor abstraction + layer, computes individual wheel slip ratios, determines the pressure + modulation phase (build/hold/release), and issues commands to the HCU + driver. Runs in the 2 ms high-priority task. + tags: [braking, abs, software] + fields: + interfaces: + provided: + - name: slip_status + type: struct + description: "struct SlipStatus { slip_ratio: [f32; 4], phase: [Phase; 4], abs_active: bool }" + required: + - name: wheel_speed_input + type: function + description: "fn read_wheel_speeds() -> [u16; 4]" + - name: hcu_command + type: function + description: "fn set_pressure_phase(wheel: u8, phase: Phase)" + concurrency: single-threaded (2 ms cyclic task) + resource-budgets: + stack: 4 KiB + wcet: 400 us + links: + - type: allocated-from + target: SWREQ-3 + + # ── Software Detailed Design / Unit Construction (SWE.3) ───────────── + + - id: SWDD-1 + type: sw-detail-design + title: Pressure demand calculation function + status: approved + description: > + Implements the brake pressure demand calculation. Reads the 12-bit + ADC pedal position value, multiplies by the load-dependent front/rear + ratio from the axle load estimator, clamps the result to the + [0, 4095] DAC range, and writes to the HCU valve driver output buffer. + Includes a rate limiter (max 500 LSB/cycle) to prevent pressure + spikes. + tags: [braking, ebd, implementation] + fields: + unit: src/braking/pressure_demand.rs + function: calculate_pressure_demand + algorithm: > + 1. Read pedal ADC (12-bit, 0-4095). + 2. Read axle load ratio (front_ratio, rear_ratio) from estimator. + 3. front_demand = clamp(pedal * front_ratio, 0, 4095). + 4. rear_demand = clamp(pedal * rear_ratio, 0, 4095). + 5. Apply rate limiter: abs(demand - prev_demand) <= 500. + 6. Write to HCU output buffer. + links: + - type: refines + target: SWARCH-1 + + - id: SWDD-2 + type: sw-detail-design + title: Wheel slip ratio computation and phase selector + status: approved + description: > + Computes individual wheel slip ratios from raw wheel speed sensor + ticks and vehicle reference speed. Implements the ABS phase state + machine: NORMAL -> BUILD -> HOLD -> RELEASE -> NORMAL based on slip + threshold crossings with hysteresis. Transition thresholds are + calibratable parameters stored in NVM. + tags: [braking, abs, implementation] + fields: + unit: src/braking/slip_control.rs + function: compute_slip_and_select_phase + algorithm: > + 1. Convert wheel speed ticks to m/s using calibration factor. + 2. Estimate vehicle reference speed as max(wheel_speeds). + 3. slip[i] = (v_ref - v_wheel[i]) / v_ref (guard div-by-zero). + 4. Phase state machine per wheel: + - NORMAL: if slip > threshold_high -> BUILD + - BUILD: if slip > threshold_release -> HOLD + - HOLD: if slip < threshold_low -> RELEASE + - RELEASE: if slip < threshold_normal -> NORMAL + 5. Output phase commands to HCU driver. + links: + - type: refines + target: SWARCH-2 diff --git a/examples/aspice/artifacts/requirements.yaml b/examples/aspice/artifacts/requirements.yaml new file mode 100644 index 0000000..b498bda --- /dev/null +++ b/examples/aspice/artifacts/requirements.yaml @@ -0,0 +1,156 @@ +artifacts: + # ── Stakeholder Requirements (SYS.1) ────────────────────────────────── + + - id: STKH-1 + type: stakeholder-req + title: Electronic Brake Force Distribution + status: approved + description: > + The vehicle shall distribute braking force between front and rear axles + electronically, adapting to load conditions, to ensure stable and + predictable deceleration across all operating conditions. + tags: [braking, ebd] + fields: + priority: must + source: customer + + - id: STKH-2 + type: stakeholder-req + title: Anti-lock Braking System + status: approved + description: > + The vehicle shall prevent wheel lock-up during emergency braking on all + surface types to maintain steering control and reduce stopping distance, + compliant with ECE R13-H and FMVSS 135. + tags: [braking, abs] + fields: + priority: must + source: regulation + + # ── System Requirements (SYS.2) ─────────────────────────────────────── + + - id: SYSREQ-1 + type: system-req + title: Brake pressure modulation per axle + status: approved + description: > + The braking system shall independently modulate brake pressure on front + and rear axles within 10 ms control cycle time, using proportional + solenoid valves driven by the hydraulic control unit. + tags: [braking, ebd, hydraulics] + fields: + req-type: functional + priority: must + verification-criteria: > + Measure brake pressure response on a dynamometer at each axle during + step and ramp demand profiles; confirm independent modulation within + 10 ms cycle time. + links: + - type: derives-from + target: STKH-1 + + - id: SYSREQ-2 + type: system-req + title: Dynamic load-dependent brake force ratio + status: approved + description: > + The system shall compute the front-to-rear brake force ratio as a + function of estimated vehicle deceleration, axle load transfer, and + surface friction coefficient, updating the ratio every control cycle. + tags: [braking, ebd, control] + fields: + req-type: functional + priority: must + verification-criteria: > + Verify computed brake force ratio against reference model output for + a set of deceleration, load, and friction scenarios on a + hardware-in-the-loop bench. + links: + - type: derives-from + target: STKH-1 + + - id: SYSREQ-3 + type: system-req + title: Wheel slip regulation + status: approved + description: > + The ABS controller shall regulate individual wheel slip to the target + slip ratio (10-20 % depending on surface) by modulating brake pressure + through build, hold, and release phases, achieving regulation within + 3 pressure cycles after lock-up onset detection. + tags: [braking, abs, control] + fields: + req-type: functional + priority: must + verification-criteria: > + Execute full-vehicle ABS stops on low-mu, split-mu, and high-mu + surfaces; confirm wheel slip stays within target band and regulation + onset occurs within 3 pressure cycles. + links: + - type: derives-from + target: STKH-2 + + # ── Software Requirements (SWE.1) ───────────────────────────────────── + + - id: SWREQ-1 + type: sw-req + title: Brake pressure demand calculation + status: approved + description: > + The software shall calculate the target brake pressure for each axle + based on driver brake pedal input, vehicle speed, and the load-dependent + ratio, outputting a 12-bit DAC command to the hydraulic valve driver + every 10 ms. + tags: [braking, ebd, software] + fields: + req-type: functional + priority: must + verification-criteria: > + Unit test the pressure demand function with boundary and nominal pedal + input, speed, and ratio combinations; verify DAC output within +/- 1 LSB + of the reference model. + links: + - type: derives-from + target: SYSREQ-1 + + - id: SWREQ-2 + type: sw-req + title: Axle load estimator + status: approved + description: > + The software shall estimate front and rear axle loads using longitudinal + acceleration from the inertial measurement unit and static weight + distribution parameters, updating the estimate every 10 ms with a + first-order low-pass filter (time constant 50 ms). + tags: [braking, ebd, estimation] + fields: + req-type: functional + priority: must + verification-criteria: > + Inject known acceleration profiles and verify estimated axle loads + against a Simulink reference model; maximum steady-state error + shall not exceed 2 % of nominal axle load. + links: + - type: derives-from + target: SYSREQ-2 + + - id: SWREQ-3 + type: sw-req + title: ABS slip control algorithm + status: approved + description: > + The software shall implement a threshold-based ABS slip control + algorithm that reads wheel speed sensor inputs at 500 Hz, computes + individual wheel slip ratios, and commands pressure build, hold, or + release actions to maintain each wheel within the target slip window. + tags: [braking, abs, software] + fields: + req-type: functional + priority: must + verification-criteria: > + Execute model-in-the-loop tests with recorded wheel speed data from + ice, wet, and dry surfaces; verify that slip regulation commands + match the validated reference controller output. + links: + - type: derives-from + target: SYSREQ-3 diff --git a/examples/aspice/artifacts/verification.yaml b/examples/aspice/artifacts/verification.yaml new file mode 100644 index 0000000..146dc31 --- /dev/null +++ b/examples/aspice/artifacts/verification.yaml @@ -0,0 +1,316 @@ +artifacts: + # ── Unit Verification (SWE.4) ──────────────────────────────────────── + + - id: UVER-1 + type: unit-verification + title: Pressure demand calculation unit tests + status: approved + description: > + Automated unit tests for the pressure demand calculation function. + Covers nominal pedal inputs, boundary conditions (0 and 4095), + rate limiter activation, and axle load ratio extremes. + tags: [braking, ebd, unit-test] + fields: + method: automated-test + preconditions: + - Rust test harness with mock HCU output buffer + - Calibration constants loaded from test fixture + steps: + - step: 1 + action: Call calculate_pressure_demand with pedal=0, ratio=(0.6, 0.4) + expected: front_demand=0, rear_demand=0 + - step: 2 + action: Call with pedal=4095, ratio=(0.6, 0.4) + expected: front_demand=2457, rear_demand=1638 + - step: 3 + action: Call with pedal=4095 after previous pedal=0 (rate limiter test) + expected: Demand increases by at most 500 per cycle + - step: 4 + action: Call with pedal=2048, ratio=(1.0, 0.0) — full front bias + expected: front_demand=2048, rear_demand=0 + links: + - type: verifies + target: SWDD-1 + + - id: UVER-2 + type: unit-verification + title: Slip ratio and phase state machine unit tests + status: approved + description: > + Automated unit tests for the wheel slip computation and ABS phase + state machine. Tests nominal slip calculation, divide-by-zero guard, + and all state transitions with calibratable thresholds. + tags: [braking, abs, unit-test] + fields: + method: automated-test + preconditions: + - Rust test harness with mock wheel speed sensor inputs + - NVM calibration parameters loaded from test fixture + steps: + - step: 1 + action: Set all wheel speeds equal to reference speed + expected: Slip ratio = 0.0 for all wheels, phase = NORMAL + - step: 2 + action: Set one wheel speed to 80 % of reference (20 % slip) + expected: Slip ratio = 0.2, phase transitions to BUILD + - step: 3 + action: Set reference speed to 0 (vehicle stationary) + expected: Slip ratio clamped to 0.0, no divide-by-zero + - step: 4 + action: Simulate full ABS cycle (NORMAL -> BUILD -> HOLD -> RELEASE -> NORMAL) + expected: Each phase transition occurs at correct threshold crossings + links: + - type: verifies + target: SWDD-2 + + # ── Software Integration Verification (SWE.5) ──────────────────────── + + - id: SWINTVER-1 + type: sw-integration-verification + title: Brake Pressure Manager integration verification + status: approved + description: > + Integration test verifying the Brake Pressure Manager component + interfaces. Validates that the pressure demand output is correctly + consumed by the HCU valve driver and that the axle load estimator + input interface provides consistent data across task boundaries. + tags: [braking, ebd, integration] + fields: + method: automated-test + preconditions: + - Software-in-the-loop environment with HCU driver stub + - Axle load estimator component running in parallel task + steps: + - step: 1 + action: Run 10 ms cyclic task for 100 cycles with ramp pedal input + expected: Pressure demand output follows pedal ramp with correct ratio + - step: 2 + action: Inject a step change in axle load estimate mid-cycle + expected: Pressure ratio adapts within one control cycle (10 ms) + - step: 3 + action: Verify inter-component data consistency under task preemption + expected: No data tearing in shared axle load structure + links: + - type: verifies + target: SWARCH-1 + + - id: SWINTVER-2 + type: sw-integration-verification + title: ABS Slip Controller integration verification + status: approved + description: > + Integration test verifying the ABS Slip Controller component + interfaces with the wheel speed sensor abstraction layer and the + HCU command interface. Validates end-to-end data flow from sensor + read to pressure phase command output. + tags: [braking, abs, integration] + fields: + method: automated-test + preconditions: + - Software-in-the-loop environment with sensor and HCU driver stubs + - Simulated wheel speed profiles for ABS activation scenario + steps: + - step: 1 + action: Run 2 ms cyclic task with all wheels at constant speed + expected: No ABS intervention, all phases remain NORMAL + - step: 2 + action: Inject sudden wheel deceleration on one wheel (simulated lock-up) + expected: ABS activates within 3 control cycles, phase transitions to BUILD + - step: 3 + action: Verify HCU command output matches expected phase sequence + expected: Build, hold, release commands issued in correct order + links: + - type: verifies + target: SWARCH-2 + + # ── Software Verification (SWE.6) ──────────────────────────────────── + + - id: SWVER-1 + type: sw-verification + title: Brake pressure demand and axle load estimation verification + status: approved + description: > + Software-level verification of the brake pressure demand calculation + and axle load estimator against their software requirements. + Conducted on the target microcontroller using hardware-in-the-loop + simulation with calibrated brake pedal and IMU sensor inputs. + tags: [braking, ebd, hil] + fields: + method: automated-test + preconditions: + - Hardware-in-the-loop bench with calibrated pedal sensor simulator + - IMU signal generator for acceleration profiles + - CAN FD bus analyzer monitoring HCU commands + steps: + - step: 1 + action: Apply 50 % pedal input at 60 km/h on level road + expected: DAC output matches expected pressure demand within +/- 1 LSB + - step: 2 + action: Apply full braking during 0.8 g deceleration + expected: Axle load estimate shifts ratio towards front axle within 2 % + - step: 3 + action: Release brake pedal rapidly + expected: Pressure demand ramps down respecting rate limiter + links: + - type: verifies + target: SWREQ-1 + - type: verifies + target: SWREQ-2 + + - id: SWVER-2 + type: sw-verification + title: ABS slip control algorithm verification + status: approved + description: > + Software-level verification of the ABS slip control algorithm against + its software requirement. Uses a vehicle dynamics model in the + hardware-in-the-loop environment to simulate lock-up scenarios on + various road surfaces. + tags: [braking, abs, hil] + fields: + method: automated-test + preconditions: + - Hardware-in-the-loop bench with vehicle dynamics model (CarMaker) + - Wheel speed sensor emulation (4 channels, 48 teeth) + - Road surface friction profiles (ice, wet, dry, split-mu) + steps: + - step: 1 + action: Emergency braking at 100 km/h on dry asphalt (mu = 0.9) + expected: No wheel lock-up, slip stays within 10-15 % target band + - step: 2 + action: Emergency braking at 80 km/h on ice (mu = 0.15) + expected: ABS activates, slip regulated within 10-20 % band + - step: 3 + action: Emergency braking at 60 km/h on split-mu (left ice, right dry) + expected: Independent wheel regulation, vehicle maintains directional stability + links: + - type: verifies + target: SWREQ-3 + + # ── System Integration Verification (SYS.4) ────────────────────────── + + - id: SYSINTVER-1 + type: sys-integration-verification + title: HCU integration verification + status: approved + description: > + System integration verification of the Hydraulic Control Unit with + the ABS ECU. Validates the CAN FD command interface, solenoid valve + response timing, and pressure sensor feedback loop on the physical + brake system test bench. + tags: [braking, hcu, system-integration] + fields: + method: automated-test + preconditions: + - Physical brake system test bench with HCU and ABS ECU + - CAN FD bus connected and operational + - Brake fluid system primed and bled + steps: + - step: 1 + action: Send pressure build command for front axle via CAN FD + expected: Front solenoid valve opens within 5 ms, pressure rises + - step: 2 + action: Send hold command followed by release command + expected: Pressure holds stable, then decreases within 10 ms + - step: 3 + action: Verify pressure sensor feedback matches commanded pressure + expected: Feedback within 3 % of commanded value at steady state + links: + - type: verifies + target: SYSARCH-1 + + - id: SYSINTVER-2 + type: sys-integration-verification + title: ABS ECU integration verification + status: approved + description: > + System integration verification of the ABS ECU with wheel speed + sensors and the HCU. Validates the complete sensor-to-actuator + signal chain on the vehicle integration test bench. + tags: [braking, abs, system-integration] + fields: + method: automated-test + preconditions: + - Vehicle integration test bench with all four wheel speed sensors + - ABS ECU connected to HCU via CAN FD + - Wheel speed simulation via motor-driven tone wheels + steps: + - step: 1 + action: Spin all tone wheels at constant speed (60 km/h equivalent) + expected: ECU reads four valid wheel speeds, ABS inactive + - step: 2 + action: Decelerate one tone wheel rapidly (simulate lock-up) + expected: ECU detects slip, sends pressure modulation commands to HCU + - step: 3 + action: Verify end-to-end latency from sensor event to valve actuation + expected: Total latency below 6 ms (2 ms computation + 4 ms CAN + valve) + links: + - type: verifies + target: SYSARCH-2 + + # ── System Verification (SYS.5) ────────────────────────────────────── + + - id: SYSVER-1 + type: sys-verification + title: Brake pressure modulation and load-dependent ratio system test + status: approved + description: > + Full system verification of brake pressure modulation and dynamic + load-dependent ratio on the vehicle dynamometer. Validates against + system requirements for axle-independent modulation and load-based + ratio adaptation. + tags: [braking, ebd, dynamometer] + fields: + method: automated-test + preconditions: + - Vehicle on chassis dynamometer with brake pressure transducers + - Vehicle loaded to GVW (Gross Vehicle Weight) + - Data acquisition system recording at 1 kHz + steps: + - step: 1 + action: Apply 50 % brake pedal at 100 km/h, measure front and rear pressure + expected: Independent pressure modulation with front/rear ratio matching load + - step: 2 + action: Repeat with vehicle at curb weight (reduced rear load) + expected: Ratio shifts towards front axle compared to GVW test + - step: 3 + action: Apply step pedal input, measure pressure response time + expected: Pressure responds within 10 ms control cycle at each axle + links: + - type: verifies + target: SYSREQ-1 + - type: verifies + target: SYSREQ-2 + + - id: SYSVER-2 + type: sys-verification + title: ABS wheel slip regulation system test + status: approved + description: > + Full system verification of ABS wheel slip regulation on the proving + ground. Validates against the system requirement for slip regulation + within the target band on multiple surface types. + tags: [braking, abs, proving-ground] + fields: + method: manual-test + preconditions: + - Instrumented test vehicle on proving ground + - Low-mu (basalt tile), split-mu, and high-mu (dry asphalt) surfaces + - Optical wheel speed reference measurement system + - On-board data logger recording slip ratios and pressure commands + steps: + - step: 1 + action: Emergency stop from 80 km/h on dry asphalt + expected: No wheel lock-up, stopping distance within ECE R13-H limit + - step: 2 + action: Emergency stop from 60 km/h on wet basalt tiles (mu ~ 0.3) + expected: ABS activates, slip regulated within 10-20 % band + - step: 3 + action: Emergency stop from 60 km/h on split-mu surface + expected: ABS regulates each side independently, vehicle tracks straight + - step: 4 + action: Verify regulation onset timing + expected: Slip regulation achieved within 3 pressure cycles of lock-up onset + links: + - type: verifies + target: SYSREQ-3 diff --git a/examples/aspice/docs/sdd.md b/examples/aspice/docs/sdd.md new file mode 100644 index 0000000..fe6f669 --- /dev/null +++ b/examples/aspice/docs/sdd.md @@ -0,0 +1,112 @@ +--- +id: SDD-001 +type: design +title: Software Design Document — Electronic Braking System +status: approved +glossary: + EBD: Electronic Brake Force Distribution + ABS: Anti-lock Braking System + HCU: Hydraulic Control Unit + ECU: Electronic Control Unit + NVM: Non-Volatile Memory + WCET: Worst-Case Execution Time + DAC: Digital-to-Analog Converter + ADC: Analog-to-Digital Converter + IMU: Inertial Measurement Unit +--- + +# Software Design Document — Electronic Braking System + +## 1. Introduction + +This document describes the software design for the Electronic Braking +System (EBS), covering both the Electronic Brake Force Distribution (EBD) +and Anti-lock Braking System (ABS) functions. The design is structured +into two major software architecture components, each decomposed into +detailed design units. + +## 2. Software Architecture Overview + +The braking software runs on a dual-core automotive microcontroller. +The architecture is divided into two components aligned with the V-model: + +- **[[SWARCH-1]]** — Brake Pressure Manager: responsible for computing + axle-level brake pressure demands based on driver input and load + distribution. Executes in the 10 ms periodic task on Core 0. + +- **[[SWARCH-2]]** — ABS Slip Controller: responsible for detecting + incipient wheel lock-up and modulating brake pressure to maintain + wheel slip within the target band. Executes in the 2 ms high-priority + task on Core 1. + +## 3. Detailed Design + +### 3.1 Pressure Demand Calculation + +The pressure demand function (**[[SWDD-1]]**) is the core of the EBD +subsystem. It converts driver pedal input into calibrated brake pressure +commands for the front and rear axles. + +**Algorithm outline:** + +1. Read the 12-bit ADC pedal position (0–4095). +2. Retrieve the current front/rear axle load ratio from the axle load + estimator. +3. Compute `front_demand = clamp(pedal * front_ratio, 0, 4095)`. +4. Compute `rear_demand = clamp(pedal * rear_ratio, 0, 4095)`. +5. Apply a rate limiter (maximum 500 LSB per 10 ms cycle) to prevent + hydraulic pressure spikes. +6. Write the results to the HCU valve driver output buffer. + +The rate limiter is critical for driver comfort and valve protection. +Calibration constants (ratio bounds, rate limit) are stored in NVM and +can be updated via the UDS WriteDataByIdentifier service. + +### 3.2 Wheel Slip Ratio and Phase Selection + +The slip controller (**[[SWDD-2]]**) implements the ABS regulation +algorithm. It runs at 500 Hz (2 ms cycle) to achieve the required +response time. + +**Slip ratio computation:** + +``` +slip[i] = (v_ref - v_wheel[i]) / v_ref +``` + +where `v_ref` is the estimated vehicle reference speed (maximum of all +wheel speeds) and `v_wheel[i]` is the speed of wheel `i`. A +divide-by-zero guard clamps the ratio to 0.0 when the vehicle is +stationary. + +**Phase state machine (per wheel):** + +| Current State | Condition | Next State | +|---------------|------------------------------|------------| +| NORMAL | slip > threshold_high | BUILD | +| BUILD | slip > threshold_release | HOLD | +| HOLD | slip < threshold_low | RELEASE | +| RELEASE | slip < threshold_normal | NORMAL | + +Threshold values are calibratable NVM parameters with hysteresis to +prevent oscillation at state boundaries. + +## 4. Interface Summary + +The two architecture components interact through shared data structures +protected by the AUTOSAR RTE mechanism: + +| Interface | Producer | Consumer | Rate | +|------------------------|---------------|----------------|--------| +| Axle load estimate | [[SWARCH-1]] | [[SWARCH-1]] | 10 ms | +| Pressure demand output | [[SWARCH-1]] | HCU driver | 10 ms | +| Wheel speed input | Sensor HAL | [[SWARCH-2]] | 2 ms | +| Slip status output | [[SWARCH-2]] | Vehicle bus | 10 ms | +| HCU phase commands | [[SWARCH-2]] | HCU driver | 2 ms | + +## 5. Resource Budgets + +| Component | Stack | WCET | Priority | +|----------------|--------|---------|----------| +| [[SWARCH-1]] | 2 KiB | 200 us | Medium | +| [[SWARCH-2]] | 4 KiB | 400 us | High | diff --git a/examples/aspice/rivet.yaml b/examples/aspice/rivet.yaml new file mode 100644 index 0000000..cdf760c --- /dev/null +++ b/examples/aspice/rivet.yaml @@ -0,0 +1,14 @@ +# Run: rivet --schemas ../../schemas validate +project: + name: aspice-braking-system + version: "1.0.0" + schemas: + - common + - aspice + +sources: + - path: artifacts + format: generic-yaml + +docs: + - docs diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 52c7276..45fb16f 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -4,6 +4,7 @@ use std::process::ExitCode; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use rivet_core::coverage; use rivet_core::diff::{ArtifactDiff, DiagnosticDiff}; use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; @@ -35,6 +36,21 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Initialize a new rivet project + Init { + /// Project name (defaults to directory name) + #[arg(long)] + name: Option, + + /// Schemas to include (e.g. common,dev or common,aspice) + #[arg(long, value_delimiter = ',', default_values_t = ["common".to_string(), "dev".to_string()])] + schema: Vec, + + /// Directory to initialize (defaults to current directory) + #[arg(long, default_value = ".")] + dir: PathBuf, + }, + /// Validate artifacts against schemas Validate, @@ -52,6 +68,17 @@ enum Command { /// Show artifact summary statistics Stats, + /// Show traceability coverage report + Coverage { + /// Output format: "table" (default) or "json" + #[arg(short, long, default_value = "table")] + format: String, + + /// Exit with failure if overall coverage is below this percentage + #[arg(long)] + fail_under: Option, + }, + /// Generate a traceability matrix Matrix { /// Source artifact type @@ -155,11 +182,18 @@ fn main() -> ExitCode { } fn run(cli: Cli) -> Result { + // Init does not need a loaded project; handle it first. + if let Command::Init { name, schema, dir } = &cli.command { + return cmd_init(name.as_deref(), schema, dir); + } + match &cli.command { + Command::Init { .. } => unreachable!(), Command::Stpa { path, schema } => cmd_stpa(path, schema.as_deref(), &cli), Command::Validate => cmd_validate(&cli), Command::List { r#type, status } => cmd_list(&cli, r#type.as_deref(), status.as_deref()), Command::Stats => cmd_stats(&cli), + Command::Coverage { format, fail_under } => cmd_coverage(&cli, format, fail_under.as_ref()), Command::Matrix { from, to, @@ -184,6 +218,122 @@ fn run(cli: Cli) -> Result { } } +/// Initialize a new rivet project. +fn cmd_init(name: Option<&str>, schemas: &[String], dir: &std::path::Path) -> Result { + let dir = if dir == std::path::Path::new(".") { + std::env::current_dir().context("resolving current directory")? + } else { + dir.to_path_buf() + }; + + let project_name = name.map(|s| s.to_string()).unwrap_or_else(|| { + dir.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "my-project".to_string()) + }); + + // Check for existing rivet.yaml + let config_path = dir.join("rivet.yaml"); + if config_path.exists() { + eprintln!( + "warning: {} already exists, skipping init", + config_path.display() + ); + return Ok(false); + } + + // Ensure the target directory exists + std::fs::create_dir_all(&dir) + .with_context(|| format!("creating directory {}", dir.display()))?; + + // Build schema list for the config + let schema_entries: String = schemas + .iter() + .map(|s| format!(" - {s}")) + .collect::>() + .join("\n"); + + // Write rivet.yaml + let config_content = format!( + "\ +project: + name: {project_name} + version: \"0.1.0\" + schemas: +{schema_entries} + +sources: + - path: artifacts + format: generic-yaml +" + ); + std::fs::write(&config_path, &config_content) + .with_context(|| format!("writing {}", config_path.display()))?; + println!(" created {}", config_path.display()); + + // Create artifacts/ directory with a sample file + let artifacts_dir = dir.join("artifacts"); + std::fs::create_dir_all(&artifacts_dir) + .with_context(|| format!("creating {}", artifacts_dir.display()))?; + + let sample_artifact_path = artifacts_dir.join("requirements.yaml"); + let sample_artifact = "\ +artifacts: + - id: REQ-001 + type: requirement + title: First requirement + status: draft + description: > + Describe what the system shall do. + tags: [core] + fields: + priority: must + category: functional +"; + std::fs::write(&sample_artifact_path, sample_artifact) + .with_context(|| format!("writing {}", sample_artifact_path.display()))?; + println!(" created {}", sample_artifact_path.display()); + + // Create docs/ directory with a sample document + let docs_dir = dir.join("docs"); + std::fs::create_dir_all(&docs_dir) + .with_context(|| format!("creating {}", docs_dir.display()))?; + + let sample_doc_path = docs_dir.join("getting-started.md"); + let sample_doc = format!( + "\ +# {project_name} + +Getting started with your rivet project. + +## Overview + +This project uses [rivet](https://github.com/pulseengine/rivet) for SDLC artifact +traceability and validation. Artifacts are stored as YAML files in `artifacts/` and +validated against schemas listed in `rivet.yaml`. + +## Quick start + +```bash +rivet validate # Validate all artifacts +rivet list # List all artifacts +rivet stats # Show summary statistics +``` +" + ); + std::fs::write(&sample_doc_path, &sample_doc) + .with_context(|| format!("writing {}", sample_doc_path.display()))?; + println!(" created {}", sample_doc_path.display()); + + println!( + "\nInitialized rivet project '{}' in {}", + project_name, + dir.display() + ); + + Ok(true) +} + /// Load STPA files directly and validate them. fn cmd_stpa( stpa_dir: &std::path::Path, @@ -336,6 +486,68 @@ fn cmd_stats(cli: &Cli) -> Result { Ok(true) } +/// Show traceability coverage report. +fn cmd_coverage(cli: &Cli, format: &str, fail_under: Option<&f64>) -> Result { + let (store, schema, graph) = load_project(cli)?; + let report = coverage::compute_coverage(&store, &schema, &graph); + + if format == "json" { + let json = report + .to_json() + .map_err(|e| anyhow::anyhow!("json serialization: {e}"))?; + println!("{json}"); + } else { + println!("Traceability Coverage Report\n"); + println!( + " {:<30} {:<20} {:>8} {:>8} {:>8}", + "Rule", "Source Type", "Covered", "Total", "%" + ); + println!(" {}", "-".repeat(80)); + + for entry in &report.entries { + println!( + " {:<30} {:<20} {:>8} {:>8} {:>7.1}%", + entry.rule_name, + entry.source_type, + entry.covered, + entry.total, + entry.percentage() + ); + } + + let overall = report.overall_coverage(); + println!(" {}", "-".repeat(80)); + println!(" {:<52} {:>7.1}%", "Overall (weighted)", overall); + + // Show uncovered artifacts + let has_uncovered = report.entries.iter().any(|e| !e.uncovered_ids.is_empty()); + if has_uncovered { + println!("\nUncovered artifacts:"); + for entry in &report.entries { + if !entry.uncovered_ids.is_empty() { + println!(" {} ({}):", entry.rule_name, entry.source_type); + for id in &entry.uncovered_ids { + println!(" {}", id); + } + } + } + } + } + + if let Some(&threshold) = fail_under { + let overall = report.overall_coverage(); + if overall < threshold { + eprintln!( + "\nerror: overall coverage {:.1}% is below threshold {:.1}%", + overall, threshold + ); + return Ok(false); + } + } + + Ok(true) +} + /// Generate a traceability matrix. fn cmd_matrix( cli: &Cli, diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index 68dee8b..9a83e27 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -12,6 +12,7 @@ use petgraph::visit::EdgeRef; use etch::filter::ego_subgraph; use etch::layout::{self as pgv_layout, EdgeInfo, LayoutOptions, NodeInfo}; use etch::svg::{SvgOptions, render_svg}; +use rivet_core::coverage; use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; use rivet_core::matrix::{self, Direction}; @@ -51,8 +52,10 @@ pub async fn run( .route("/matrix", get(matrix_view)) .route("/graph", get(graph_view)) .route("/stats", get(stats_view)) + .route("/coverage", get(coverage_view)) .route("/documents", get(documents_list)) .route("/documents/{id}", get(document_detail)) + .route("/search", get(search_view)) .with_state(state); let addr = format!("0.0.0.0:{port}"); @@ -327,6 +330,46 @@ nav a:hover .nav-icon,nav a.active .nav-icon{opacity:.9} /* ── Selection ────────────────────────────────────────────────── */ ::selection{background:rgba(58,134,255,.18)} + +/* ── Cmd+K search modal ──────────────────────────────────────── */ +.cmd-k-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(4px); + z-index:10000;display:none;align-items:flex-start;justify-content:center;padding-top:min(20vh,160px)} +.cmd-k-overlay.open{display:flex} +.cmd-k-modal{background:var(--sidebar);border-radius:12px;width:100%;max-width:600px; + box-shadow:0 16px 70px rgba(0,0,0,.35);border:1px solid rgba(255,255,255,.08); + overflow:hidden;display:flex;flex-direction:column;max-height:min(70vh,520px)} +.cmd-k-input{width:100%;padding:.875rem 1rem .875rem 2.75rem;font-size:1rem;font-family:var(--font); + background:transparent;border:none;border-bottom:1px solid rgba(255,255,255,.08); + color:#fff;outline:none;caret-color:var(--accent)} +.cmd-k-input::placeholder{color:rgba(255,255,255,.35)} +.cmd-k-icon{position:absolute;left:1rem;top:.95rem;color:rgba(255,255,255,.35);pointer-events:none; + font-size:.95rem} +.cmd-k-head{position:relative} +.cmd-k-results{overflow-y:auto;padding:.5rem 0;flex:1} +.cmd-k-empty{padding:1.5rem 1rem;text-align:center;color:rgba(255,255,255,.35);font-size:.9rem} +.cmd-k-group{padding:0 .5rem} +.cmd-k-group-label{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em; + color:rgba(255,255,255,.3);padding:.5rem .625rem .25rem} +.cmd-k-item{display:flex;align-items:center;gap:.75rem;padding:.5rem .625rem;border-radius:var(--radius-sm); + cursor:pointer;color:var(--sidebar-text);font-size:.88rem;transition:background 80ms ease} +.cmd-k-item:hover,.cmd-k-item.active{background:rgba(255,255,255,.08);color:#fff} +.cmd-k-item-icon{width:1.5rem;height:1.5rem;border-radius:4px;display:flex;align-items:center; + justify-content:center;font-size:.7rem;flex-shrink:0;background:rgba(255,255,255,.06);color:rgba(255,255,255,.5)} +.cmd-k-item-body{flex:1;min-width:0} +.cmd-k-item-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cmd-k-item-title mark{background:transparent;color:var(--accent);font-weight:700} +.cmd-k-item-meta{font-size:.75rem;color:rgba(255,255,255,.35);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cmd-k-item-meta mark{background:transparent;color:var(--accent);font-weight:600} +.cmd-k-item-field{font-size:.65rem;padding:.1rem .35rem;border-radius:3px; + background:rgba(255,255,255,.06);color:rgba(255,255,255,.4);white-space:nowrap;flex-shrink:0} +.cmd-k-kbd{display:inline-flex;align-items:center;gap:.2rem;font-size:.7rem;font-family:var(--mono); + padding:.15rem .4rem;border-radius:4px;background:rgba(255,255,255,.08);color:rgba(255,255,255,.4); + border:1px solid rgba(255,255,255,.06)} +.nav-search-hint{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem; + margin-top:auto;border-top:1px solid rgba(255,255,255,.06);padding-top:1rem; + color:var(--sidebar-text);font-size:.82rem;cursor:pointer;border-radius:var(--radius-sm); + transition:all var(--transition)} +.nav-search-hint:hover{background:var(--sidebar-hover);color:var(--sidebar-active)} "#; // ── Pan/zoom JS ────────────────────────────────────────────────────────── @@ -507,6 +550,114 @@ const GRAPH_JS: &str = r#" "#; +// ── Cmd+K search JS ────────────────────────────────────────────────────── + +const SEARCH_JS: &str = r#" + +"#; + // ── Layout ─────────────────────────────────────────────────────────────── fn page_layout(content: &str) -> Html { @@ -533,15 +684,32 @@ fn page_layout(content: &str) -> Html {

  • Artifacts
  • Validation
  • Matrix
  • +
  • Coverage
  • Graph
  • Documents
  • +
    {content}

    +
    +
    +
    + 🔍 + +
    +
    +
    Type to search artifacts and documents
    +
    +
    +
    {GRAPH_JS} +{SEARCH_JS} "## )) @@ -1450,6 +1618,127 @@ async fn matrix_view( Html(html) } +// ── Coverage ───────────────────────────────────────────────────────────── + +async fn coverage_view(State(state): State>) -> Html { + let report = coverage::compute_coverage(&state.store, &state.schema, &state.graph); + let overall = report.overall_coverage(); + + let mut html = String::from("

    Traceability Coverage

    "); + + // Overall stat + let overall_color = if overall >= 80.0 { + "#15713a" + } else if overall >= 50.0 { + "#8b6914" + } else { + "#c62828" + }; + html.push_str("
    "); + html.push_str(&format!( + "
    {:.1}%
    Overall Coverage
    ", + overall + )); + html.push_str(&format!( + "
    {}
    Rules
    ", + report.entries.len() + )); + let fully_covered = report + .entries + .iter() + .filter(|e| e.covered == e.total) + .count(); + html.push_str(&format!( + "
    {}
    Fully Covered
    ", + fully_covered + )); + html.push_str("
    "); + + if report.entries.is_empty() { + html.push_str( + "

    No traceability rules defined in the schema.

    ", + ); + return Html(html); + } + + // Per-rule cards with coverage bars + html.push_str("

    Coverage by Rule

    "); + html.push_str(""); + + for entry in &report.entries { + let pct = entry.percentage(); + let (bar_color, badge_class) = if pct >= 80.0 { + ("#15713a", "badge-ok") + } else if pct >= 50.0 { + ("#b8860b", "badge-warn") + } else { + ("#c62828", "badge-error") + }; + + let dir_label = match entry.direction { + coverage::CoverageDirection::Forward => "forward", + coverage::CoverageDirection::Backward => "backward", + }; + + html.push_str(&format!( + "\ + \ + \ + \ + \ + \ + \ + ", + html_escape(&entry.description), + html_escape(&entry.rule_name), + html_escape(&entry.source_type), + html_escape(&entry.link_type), + dir_label, + entry.covered, + entry.total, + pct, + )); + } + + html.push_str("
    RuleSource TypeLinkDirectionCoverageProgress
    {}{}{}{}{}/{} ({:.1}%)\ +
    \ +
    \ +
    \ +
    "); + + // Uncovered artifacts + let has_uncovered = report.entries.iter().any(|e| !e.uncovered_ids.is_empty()); + if has_uncovered { + html.push_str("

    Uncovered Artifacts

    "); + + for entry in &report.entries { + if entry.uncovered_ids.is_empty() { + continue; + } + html.push_str(&format!( + "

    {} ({} uncovered)

    ", + html_escape(&entry.rule_name), + entry.uncovered_ids.len() + )); + html.push_str(""); + for id in &entry.uncovered_ids { + let title = state.store.get(id).map(|a| a.title.as_str()).unwrap_or("-"); + html.push_str(&format!( + "\ + ", + id_esc = html_escape(id), + title_esc = html_escape(title), + )); + } + html.push_str("
    IDTitle
    {id_esc}{title_esc}
    "); + } + + html.push_str("
    "); + } + + Html(html) +} + // ── Documents ──────────────────────────────────────────────────────────── async fn documents_list(State(state): State>) -> Html { @@ -1624,6 +1913,258 @@ async fn document_detail( Html(html) } +// ── Search ─────────────────────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize)] +struct SearchParams { + q: Option, +} + +/// A single search hit with context about which field matched. +struct SearchHit { + id: String, + title: String, + kind: &'static str, + type_name: String, + matched_field: &'static str, + context: String, + url: String, +} + +async fn search_view( + State(state): State>, + Query(params): Query, +) -> Html { + let query = match params.q.as_deref() { + Some(q) if !q.trim().is_empty() => q.trim(), + _ => { + return Html(String::from( + "
    Type to search artifacts and documents
    ", + )); + } + }; + + let query_lower = query.to_lowercase(); + let mut hits: Vec = Vec::new(); + + // Search artifacts + for artifact in state.store.iter() { + let id_lower = artifact.id.to_lowercase(); + let title_lower = artifact.title.to_lowercase(); + let type_lower = artifact.artifact_type.to_lowercase(); + + if id_lower.contains(&query_lower) { + hits.push(SearchHit { + id: artifact.id.clone(), + title: artifact.title.clone(), + kind: "artifact", + type_name: artifact.artifact_type.clone(), + matched_field: "id", + context: artifact.id.clone(), + url: format!("/artifacts/{}", artifact.id), + }); + continue; + } + if title_lower.contains(&query_lower) { + hits.push(SearchHit { + id: artifact.id.clone(), + title: artifact.title.clone(), + kind: "artifact", + type_name: artifact.artifact_type.clone(), + matched_field: "title", + context: artifact.title.clone(), + url: format!("/artifacts/{}", artifact.id), + }); + continue; + } + if type_lower.contains(&query_lower) { + hits.push(SearchHit { + id: artifact.id.clone(), + title: artifact.title.clone(), + kind: "artifact", + type_name: artifact.artifact_type.clone(), + matched_field: "type", + context: artifact.artifact_type.clone(), + url: format!("/artifacts/{}", artifact.id), + }); + continue; + } + if let Some(desc) = &artifact.description { + if desc.to_lowercase().contains(&query_lower) { + let desc_lower = desc.to_lowercase(); + let pos = desc_lower.find(&query_lower).unwrap_or(0); + let start = pos.saturating_sub(40); + let end = (pos + query.len() + 40).min(desc.len()); + let mut snippet = String::new(); + if start > 0 { + snippet.push_str("..."); + } + snippet.push_str(&desc[start..end]); + if end < desc.len() { + snippet.push_str("..."); + } + hits.push(SearchHit { + id: artifact.id.clone(), + title: artifact.title.clone(), + kind: "artifact", + type_name: artifact.artifact_type.clone(), + matched_field: "description", + context: snippet, + url: format!("/artifacts/{}", artifact.id), + }); + continue; + } + } + for tag in &artifact.tags { + if tag.to_lowercase().contains(&query_lower) { + hits.push(SearchHit { + id: artifact.id.clone(), + title: artifact.title.clone(), + kind: "artifact", + type_name: artifact.artifact_type.clone(), + matched_field: "tag", + context: tag.clone(), + url: format!("/artifacts/{}", artifact.id), + }); + break; + } + } + } + + // Search documents + for doc in state.doc_store.iter() { + let id_lower = doc.id.to_lowercase(); + let title_lower = doc.title.to_lowercase(); + + if id_lower.contains(&query_lower) { + hits.push(SearchHit { + id: doc.id.clone(), + title: doc.title.clone(), + kind: "document", + type_name: doc.doc_type.clone(), + matched_field: "id", + context: doc.id.clone(), + url: format!("/documents/{}", doc.id), + }); + continue; + } + if title_lower.contains(&query_lower) { + hits.push(SearchHit { + id: doc.id.clone(), + title: doc.title.clone(), + kind: "document", + type_name: doc.doc_type.clone(), + matched_field: "title", + context: doc.title.clone(), + url: format!("/documents/{}", doc.id), + }); + } + } + + // Sort: exact id match first, then by kind, then by id + hits.sort_by(|a, b| { + let a_exact = a.id.to_lowercase() == query_lower; + let b_exact = b.id.to_lowercase() == query_lower; + b_exact + .cmp(&a_exact) + .then_with(|| a.kind.cmp(b.kind)) + .then_with(|| a.id.cmp(&b.id)) + }); + + hits.truncate(50); + + if hits.is_empty() { + return Html(format!( + "
    No results for “{}”
    ", + html_escape(query) + )); + } + + // Group by kind + let mut html = String::new(); + + let artifact_hits: Vec<&SearchHit> = hits.iter().filter(|h| h.kind == "artifact").collect(); + let document_hits: Vec<&SearchHit> = hits.iter().filter(|h| h.kind == "document").collect(); + + if !artifact_hits.is_empty() { + html.push_str("
    "); + html.push_str("
    Artifacts
    "); + for hit in &artifact_hits { + render_search_hit(&mut html, hit, query); + } + html.push_str("
    "); + } + + if !document_hits.is_empty() { + html.push_str("
    "); + html.push_str("
    Documents
    "); + for hit in &document_hits { + render_search_hit(&mut html, hit, query); + } + html.push_str("
    "); + } + + Html(html) +} + +/// Render a single search result item with highlighted match context. +fn render_search_hit(html: &mut String, hit: &SearchHit, query: &str) { + let icon = match hit.kind { + "artifact" => "♦", + "document" => "☰", + _ => "•", + }; + + let highlighted_title = highlight_match(&html_escape(&hit.title), query); + + let field_label = match hit.matched_field { + "id" => "id", + "title" => "title", + "description" => "description", + "type" => "type", + "tag" => "tag", + _ => "", + }; + + let context_display = if hit.matched_field == "title" { + String::new() + } else { + let escaped = html_escape(&hit.context); + format!(" — {}", highlight_match(&escaped, query)) + }; + + html.push_str(&format!( + "
    \ +
    {icon}
    \ +
    \ +
    {highlighted_title}
    \ +
    {}{context_display}
    \ +
    \ +
    {field_label}
    \ +
    ", + html_escape(&hit.url), + html_escape(&hit.type_name), + )); +} + +/// Case-insensitive highlight: wraps matching substrings in ``. +fn highlight_match(text: &str, query: &str) -> String { + let text_lower = text.to_lowercase(); + let query_lower = query.to_lowercase(); + let mut result = String::with_capacity(text.len() + 16); + let mut start = 0; + while let Some(pos) = text_lower[start..].find(&query_lower) { + let abs = start + pos; + result.push_str(&text[start..abs]); + result.push_str(""); + result.push_str(&text[abs..abs + query.len()]); + result.push_str(""); + start = abs + query.len(); + } + result.push_str(&text[start..]); + result +} + // ── Helpers ────────────────────────────────────────────────────────────── fn html_escape(s: &str) -> String { diff --git a/rivet-core/src/coverage.rs b/rivet-core/src/coverage.rs new file mode 100644 index 0000000..f2dc381 --- /dev/null +++ b/rivet-core/src/coverage.rs @@ -0,0 +1,317 @@ +//! Traceability coverage reporting. +//! +//! Auto-discovers traceability rules from the schema and computes +//! per-rule coverage percentages. Each rule checks whether artifacts of +//! a given source type have the required forward or backward links. + +use serde::Serialize; + +use crate::links::LinkGraph; +use crate::schema::Schema; +use crate::store::Store; + +/// Coverage result for a single traceability rule. +#[derive(Debug, Clone, Serialize)] +pub struct CoverageEntry { + /// Rule name from the schema. + pub rule_name: String, + /// Human-readable description. + pub description: String, + /// Source artifact type being checked. + pub source_type: String, + /// The link type that is required (forward or backward). + pub link_type: String, + /// Whether the check uses forward links or backlinks. + pub direction: CoverageDirection, + /// Target / from types for the required link. + pub target_types: Vec, + /// Number of source artifacts that satisfy the rule. + pub covered: usize, + /// Total source artifacts of the given type. + pub total: usize, + /// IDs of artifacts that are NOT covered. + pub uncovered_ids: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CoverageDirection { + Forward, + Backward, +} + +impl CoverageEntry { + /// Coverage percentage (0..100). Returns 100 when total is 0. + pub fn percentage(&self) -> f64 { + if self.total == 0 { + 100.0 + } else { + (self.covered as f64 / self.total as f64) * 100.0 + } + } +} + +/// Full coverage report across all traceability rules. +#[derive(Debug, Clone, Serialize)] +pub struct CoverageReport { + pub entries: Vec, +} + +impl CoverageReport { + /// Overall coverage: weighted average across all rules (by artifact count). + pub fn overall_coverage(&self) -> f64 { + let total: usize = self.entries.iter().map(|e| e.total).sum(); + if total == 0 { + return 100.0; + } + let covered: usize = self.entries.iter().map(|e| e.covered).sum(); + (covered as f64 / total as f64) * 100.0 + } + + /// Serialize the report to a JSON string. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } +} + +/// Compute coverage for every traceability rule in the schema. +pub fn compute_coverage(store: &Store, schema: &Schema, graph: &LinkGraph) -> CoverageReport { + let mut entries = Vec::new(); + + for rule in &schema.traceability_rules { + let source_ids = store.by_type(&rule.source_type); + let total = source_ids.len(); + let mut covered = 0usize; + let mut uncovered_ids = Vec::new(); + + let (link_type, direction, target_types) = if let Some(ref req_link) = rule.required_link { + ( + req_link.clone(), + CoverageDirection::Forward, + rule.target_types.clone(), + ) + } else if let Some(ref req_bl) = rule.required_backlink { + ( + req_bl.clone(), + CoverageDirection::Backward, + rule.from_types.clone(), + ) + } else { + // Rule has neither required-link nor required-backlink; skip. + continue; + }; + + for id in source_ids { + let has_match = match direction { + CoverageDirection::Forward => graph + .links_from(id) + .iter() + .filter(|l| l.link_type == link_type) + .any(|l| { + if target_types.is_empty() { + true + } else { + store + .get(&l.target) + .is_some_and(|a| target_types.contains(&a.artifact_type)) + } + }), + CoverageDirection::Backward => graph + .backlinks_to(id) + .iter() + .filter(|bl| bl.link_type == link_type) + .any(|bl| { + if target_types.is_empty() { + true + } else { + store + .get(&bl.source) + .is_some_and(|a| target_types.contains(&a.artifact_type)) + } + }), + }; + + if has_match { + covered += 1; + } else { + uncovered_ids.push(id.clone()); + } + } + + entries.push(CoverageEntry { + rule_name: rule.name.clone(), + description: rule.description.clone(), + source_type: rule.source_type.clone(), + link_type: link_type.clone(), + direction, + target_types, + covered, + total, + uncovered_ids, + }); + } + + CoverageReport { entries } +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Artifact, Link}; + use crate::schema::{SchemaFile, SchemaMetadata, Severity, TraceabilityRule}; + + fn test_schema() -> Schema { + let file = SchemaFile { + schema: SchemaMetadata { + name: "test".into(), + version: "0.1.0".into(), + namespace: None, + description: None, + extends: vec![], + }, + base_fields: vec![], + artifact_types: vec![], + link_types: vec![], + traceability_rules: vec![ + TraceabilityRule { + name: "req-coverage".into(), + description: "Every req should be satisfied".into(), + source_type: "requirement".into(), + required_link: None, + required_backlink: Some("satisfies".into()), + target_types: vec![], + from_types: vec!["design-decision".into()], + severity: Severity::Warning, + }, + TraceabilityRule { + name: "dd-justification".into(), + description: "Every DD must satisfy a req".into(), + source_type: "design-decision".into(), + required_link: Some("satisfies".into()), + required_backlink: None, + target_types: vec!["requirement".into()], + from_types: vec![], + severity: Severity::Error, + }, + ], + }; + Schema::merge(&[file]) + } + + fn make_artifact(id: &str, atype: &str, links: Vec) -> Artifact { + Artifact { + id: id.into(), + artifact_type: atype.into(), + title: id.into(), + description: None, + status: None, + tags: vec![], + links, + fields: Default::default(), + source_file: None, + } + } + + #[test] + fn full_coverage() { + let schema = test_schema(); + let mut store = Store::new(); + store + .insert(make_artifact("REQ-001", "requirement", vec![])) + .unwrap(); + store + .insert(make_artifact( + "DD-001", + "design-decision", + vec![Link { + link_type: "satisfies".into(), + target: "REQ-001".into(), + }], + )) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + + assert_eq!(report.entries.len(), 2); + + // req-coverage: REQ-001 has a backlink from DD-001 via satisfies + let req_entry = &report.entries[0]; + assert_eq!(req_entry.rule_name, "req-coverage"); + assert_eq!(req_entry.covered, 1); + assert_eq!(req_entry.total, 1); + assert!((req_entry.percentage() - 100.0).abs() < f64::EPSILON); + + // dd-justification: DD-001 has forward link satisfies -> REQ-001 + let dd_entry = &report.entries[1]; + assert_eq!(dd_entry.rule_name, "dd-justification"); + assert_eq!(dd_entry.covered, 1); + assert_eq!(dd_entry.total, 1); + + assert!((report.overall_coverage() - 100.0).abs() < f64::EPSILON); + } + + #[test] + fn partial_coverage() { + let schema = test_schema(); + let mut store = Store::new(); + store + .insert(make_artifact("REQ-001", "requirement", vec![])) + .unwrap(); + store + .insert(make_artifact("REQ-002", "requirement", vec![])) + .unwrap(); + store + .insert(make_artifact( + "DD-001", + "design-decision", + vec![Link { + link_type: "satisfies".into(), + target: "REQ-001".into(), + }], + )) + .unwrap(); + + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + + // req-coverage: 1/2 covered + let req_entry = &report.entries[0]; + assert_eq!(req_entry.covered, 1); + assert_eq!(req_entry.total, 2); + assert!((req_entry.percentage() - 50.0).abs() < f64::EPSILON); + assert_eq!(req_entry.uncovered_ids, vec!["REQ-002"]); + + // overall: 2 covered out of 3 total + assert!((report.overall_coverage() - 66.666_666_666_666_66).abs() < 0.01); + } + + #[test] + fn zero_artifacts_gives_100_percent() { + let schema = test_schema(); + let store = Store::new(); + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + + // Both rules have 0 source artifacts → percentage is 100 + for entry in &report.entries { + assert_eq!(entry.total, 0); + assert!((entry.percentage() - 100.0).abs() < f64::EPSILON); + } + assert!((report.overall_coverage() - 100.0).abs() < f64::EPSILON); + } + + #[test] + fn to_json_roundtrip() { + let schema = test_schema(); + let store = Store::new(); + let graph = LinkGraph::build(&store, &schema); + let report = compute_coverage(&store, &schema, &graph); + + let json = report.to_json().expect("serialize"); + assert!(json.contains("req-coverage")); + assert!(json.contains("dd-justification")); + } +} diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 066e718..ba6b0dd 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod adapter; +pub mod coverage; pub mod diff; pub mod document; pub mod error; From 6bf5177e13e3112db71150aa08be2818f287abea Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 14:19:28 +0100 Subject: [PATCH 5/7] Polish dashboard UI: colored stats, nav badges, footer, action buttons - Stat boxes have colored top borders (blue/green/orange/red/amber/purple) matching their semantic meaning, with matching number colors - Nav sidebar shows artifact count, error badge (red if >0), and doc count - Nav divider separates primary views from analysis tools - Artifact detail uses styled primary/secondary action buttons - Footer shows "Powered by Rivet v{version}" from CARGO_PKG_VERSION - CSS classes: stat-{color}, nav-badge, nav-badge-error, nav-divider, nav-label, btn, btn-primary, btn-secondary, detail-actions, footer Co-Authored-By: Claude Opus 4.6 --- rivet-cli/src/serve.rs | 112 ++++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index 9a83e27..eddc0fc 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -217,12 +217,19 @@ td a{font-family:var(--mono);font-size:.85rem;font-weight:500} /* ── Stat grid ────────────────────────────────────────────────── */ .stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem;margin-bottom:1.75rem} .stat-box{background:var(--surface);border-radius:var(--radius);padding:1.25rem 1rem;text-align:center; - box-shadow:var(--shadow);border:1px solid var(--border);transition:box-shadow var(--transition),transform var(--transition)} + box-shadow:var(--shadow);border:1px solid var(--border);transition:box-shadow var(--transition),transform var(--transition); + border-top:3px solid var(--border)} .stat-box:hover{box-shadow:var(--shadow-md);transform:translateY(-1px)} -.stat-box .number{font-size:2rem;font-weight:800;color:var(--accent);letter-spacing:-.02em; +.stat-box .number{font-size:2rem;font-weight:800;letter-spacing:-.02em; font-variant-numeric:tabular-nums;line-height:1.2} .stat-box .label{font-size:.8rem;font-weight:500;color:var(--text-secondary);margin-top:.25rem; text-transform:uppercase;letter-spacing:.04em} +.stat-blue{border-top-color:#3a86ff}.stat-blue .number{color:#3a86ff} +.stat-green{border-top-color:#15713a}.stat-green .number{color:#15713a} +.stat-orange{border-top-color:#e67e22}.stat-orange .number{color:#e67e22} +.stat-red{border-top-color:#c62828}.stat-red .number{color:#c62828} +.stat-amber{border-top-color:#b8860b}.stat-amber .number{color:#b8860b} +.stat-purple{border-top-color:#6f42c1}.stat-purple .number{color:#6f42c1} /* ── Link pills ───────────────────────────────────────────────── */ .link-pill{display:inline-block;padding:.15rem .45rem;border-radius:4px; @@ -262,9 +269,28 @@ dd{margin-left:0;margin-bottom:.25rem;margin-top:.2rem} /* ── Meta text ────────────────────────────────────────────────── */ .meta{color:var(--text-secondary);font-size:.85rem} -/* ── Nav icons ────────────────────────────────────────────────── */ +/* ── Nav icons & badges ───────────────────────────────────────── */ .nav-icon{display:inline-flex;width:1.25rem;justify-content:center;flex-shrink:0;font-size:.8rem;opacity:.5} nav a:hover .nav-icon,nav a.active .nav-icon{opacity:.9} +.nav-label{display:flex;align-items:center;gap:.5rem;flex:1;min-width:0} +.nav-badge{font-size:.65rem;font-weight:700;padding:.1rem .4rem;border-radius:4px; + background:rgba(255,255,255,.08);color:rgba(255,255,255,.4);margin-left:auto;flex-shrink:0} +.nav-badge-error{background:rgba(220,53,69,.2);color:#ff6b7a} +nav .nav-divider{height:1px;background:rgba(255,255,255,.06);margin:.75rem .75rem} + +/* ── Footer ──────────────────────────────────────────────────── */ +.footer{padding:2rem 0 1rem;text-align:center;font-size:.75rem;color:var(--text-secondary); + border-top:1px solid var(--border);margin-top:3rem} + +/* ── Detail actions ──────────────────────────────────────────── */ +.detail-actions{display:flex;gap:.75rem;align-items:center;margin-top:1rem} +.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem 1rem;border-radius:var(--radius-sm); + font-size:.85rem;font-weight:600;font-family:var(--font);text-decoration:none; + transition:all var(--transition);cursor:pointer;border:none} +.btn-primary{background:var(--accent);color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08)} +.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px);color:#fff;text-decoration:none} +.btn-secondary{background:transparent;color:var(--text-secondary);border:1px solid var(--border)} +.btn-secondary:hover{background:rgba(0,0,0,.03);color:var(--text);text-decoration:none} /* ── Graph ────────────────────────────────────────────────────── */ .graph-container{border-radius:var(--radius);overflow:hidden;background:#fafbfc;cursor:grab; @@ -660,7 +686,28 @@ const SEARCH_JS: &str = r#" // ── Layout ─────────────────────────────────────────────────────────────── -fn page_layout(content: &str) -> Html { +struct NavInfo { + artifact_count: usize, + error_count: usize, + doc_count: usize, +} + +fn page_layout(content: &str, nav: &NavInfo) -> Html { + let artifact_count = nav.artifact_count; + let error_badge = if nav.error_count > 0 { + format!( + "{}", + nav.error_count + ) + } else { + "OK".to_string() + }; + let doc_badge = if nav.doc_count > 0 { + format!("{}", nav.doc_count) + } else { + String::new() + }; + let version = env!("CARGO_PKG_VERSION"); Html(format!( r##" @@ -680,13 +727,14 @@ fn page_layout(content: &str) -> Html {
    {content} +
    @@ -719,7 +768,21 @@ fn page_layout(content: &str) -> Html { async fn index(State(state): State>) -> Html { let inner = stats_partial(&state); - page_layout(&inner) + let nav = make_nav_info(&state); + page_layout(&inner, &nav) +} + +fn make_nav_info(state: &AppState) -> NavInfo { + let diagnostics = validate::validate(&state.store, &state.schema, &state.graph); + let error_count = diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .count(); + NavInfo { + artifact_count: state.store.len(), + error_count, + doc_count: state.doc_store.len(), + } } async fn stats_view(State(state): State>) -> Html { @@ -747,35 +810,35 @@ fn stats_partial(state: &AppState) -> String { let mut html = String::from("

    Dashboard

    "); - // Summary cards + // Summary cards with colored accents html.push_str("
    "); html.push_str(&format!( - "
    {}
    Artifacts
    ", + "
    {}
    Artifacts
    ", store.len() )); html.push_str(&format!( - "
    {}
    Types
    ", + "
    {}
    Types
    ", types.len() )); html.push_str(&format!( - "
    {}
    Orphans
    ", + "
    {}
    Orphans
    ", orphans.len() )); html.push_str(&format!( - "
    {}
    Errors
    ", + "
    {}
    Errors
    ", errors )); html.push_str(&format!( - "
    {}
    Warnings
    ", + "
    {}
    Warnings
    ", warnings )); html.push_str(&format!( - "
    {}
    Broken Links
    ", + "
    {}
    Broken Links
    ", graph.broken.len() )); if !doc_store.is_empty() { html.push_str(&format!( - "
    {}
    Documents
    ", + "
    {}
    Documents
    ", doc_store.len() )); } @@ -945,11 +1008,12 @@ async fn artifact_detail( html.push_str("
    "); } - // Show in graph link + // Action buttons html.push_str(&format!( - r##"

    Show in graph -  |  - ← Back to artifacts

    "##, + r##""##, id_esc = html_escape(&id), )); From 7ae61f50ed86cff47ff63f1d03d53e9c8ff9e11c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 14:53:34 +0100 Subject: [PATCH 6/7] Redesign dashboard UI to Linear/Vercel quality level - Replace HTML entity icons with clean 16x16 inline SVG icons (Lucide-style stroke icons for Overview, Artifacts, Validation, Matrix, Coverage, Graph, Documents, Search) - Per-type colored badges: badge_for_type() computes rgba(r,g,b,.12) background from type_color_map() hex colors, applied across all views - Artifact list client-side search filter with inline SVG search icon - Overview status distribution with horizontal progress bars showing approved/draft/unknown ratios - Validation summary bar: green gradient for pass, red gradient for fail - Sidebar active state with left blue accent border - h2 headings with bottom border for visual separation - Better heading hierarchy throughout Co-Authored-By: Claude Opus 4.6 --- rivet-cli/src/serve.rs | 154 +++++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 36 deletions(-) diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index eddc0fc..a821d34 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use anyhow::Result; @@ -117,6 +117,27 @@ fn type_color_map() -> HashMap { .collect() } +/// Return a colored badge `` for an artifact type. +/// +/// Uses the `type_color_map` hex color as text and computes a 12%-opacity +/// tinted background from it. +fn badge_for_type(type_name: &str) -> String { + let colors = type_color_map(); + let hex = colors + .get(type_name) + .map(|s| s.as_str()) + .unwrap_or("#5b2d9e"); + // Parse hex → rgb + let hex_digits = hex.trim_start_matches('#'); + let r = u8::from_str_radix(&hex_digits[0..2], 16).unwrap_or(91); + let g = u8::from_str_radix(&hex_digits[2..4], 16).unwrap_or(45); + let b = u8::from_str_radix(&hex_digits[4..6], 16).unwrap_or(158); + format!( + "{}", + html_escape(type_name) + ) +} + // ── CSS ────────────────────────────────────────────────────────────────── const CSS: &str = r#" @@ -169,7 +190,7 @@ nav a{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-radi color:var(--sidebar-text);font-size:.875rem;font-weight:500; transition:all var(--transition)} nav a:hover{background:var(--sidebar-hover);color:var(--sidebar-active);text-decoration:none} -nav a.active{background:var(--sidebar-hover);color:var(--sidebar-active)} +nav a.active{background:rgba(58,134,255,.08);color:var(--sidebar-active);border-left:2px solid var(--accent);padding-left:calc(.75rem - 2px)} nav a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} /* ── Main content ─────────────────────────────────────────────── */ @@ -184,7 +205,7 @@ main.htmx-settling{opacity:1;transition:opacity 200ms ease-in} #loading-bar.done{width:100%;transition:width 100ms ease;opacity:0;transition:width 100ms ease,opacity 300ms ease 100ms} /* ── Typography ───────────────────────────────────────────────── */ -h2{font-size:1.4rem;font-weight:700;margin-bottom:1.25rem;color:var(--text);letter-spacing:-.01em} +h2{font-size:1.4rem;font-weight:700;margin-bottom:1.25rem;color:var(--text);letter-spacing:-.01em;padding-bottom:.75rem;border-bottom:1px solid var(--border)} h3{font-size:1.05rem;font-weight:600;margin:1.5rem 0 .75rem;color:var(--text)} code,pre{font-family:var(--mono);font-size:.85em} pre{background:#f1f1f3;padding:1rem;border-radius:var(--radius-sm);overflow-x:auto} @@ -209,6 +230,18 @@ td a{font-family:var(--mono);font-size:.85rem;font-weight:500} .badge-ok{background:#e6f9ed;color:#15713a} .badge-type{background:#f0ecf9;color:#5b2d9e;font-family:var(--mono);font-size:.72rem} +/* ── Validation bar ──────────────────────────────────────────── */ +.validation-bar{padding:1rem 1.25rem;border-radius:var(--radius);margin-bottom:1.25rem;font-weight:600;font-size:.95rem} +.validation-bar.pass{background:linear-gradient(135deg,#e6f9ed,#d4f5e0);color:#15713a;border:1px solid #b8e8c8} +.validation-bar.fail{background:linear-gradient(135deg,#fee,#fdd);color:#c62828;border:1px solid #f4c7c3} + +/* ── Status progress bars ────────────────────────────────────── */ +.status-bar-row{display:flex;align-items:center;gap:.75rem;margin-bottom:.5rem;font-size:.85rem} +.status-bar-label{width:80px;text-align:right;font-weight:500;color:var(--text-secondary)} +.status-bar-track{flex:1;height:20px;background:#e5e5ea;border-radius:4px;overflow:hidden;position:relative} +.status-bar-fill{height:100%;border-radius:4px;transition:width .3s ease} +.status-bar-count{width:40px;font-variant-numeric:tabular-nums;color:var(--text-secondary)} + /* ── Cards ────────────────────────────────────────────────────── */ .card{background:var(--surface);border-radius:var(--radius);padding:1.5rem; margin-bottom:1.25rem;box-shadow:var(--shadow);border:1px solid var(--border); @@ -270,7 +303,7 @@ dd{margin-left:0;margin-bottom:.25rem;margin-top:.2rem} .meta{color:var(--text-secondary);font-size:.85rem} /* ── Nav icons & badges ───────────────────────────────────────── */ -.nav-icon{display:inline-flex;width:1.25rem;justify-content:center;flex-shrink:0;font-size:.8rem;opacity:.5} +.nav-icon{display:inline-flex;width:1.25rem;height:1.25rem;align-items:center;justify-content:center;flex-shrink:0;opacity:.5} nav a:hover .nav-icon,nav a.active .nav-icon{opacity:.9} .nav-label{display:flex;align-items:center;gap:.5rem;flex:1;min-width:0} .nav-badge{font-size:.65rem;font-weight:700;padding:.1rem .4rem;border-radius:4px; @@ -727,17 +760,17 @@ fn page_layout(content: &str, nav: &NavInfo) -> Html { @@ -848,12 +881,43 @@ fn stats_partial(state: &AppState) -> String { html.push_str("

    Artifacts by Type

    "); for t in &types { html.push_str(&format!( - "", + "", + badge_for_type(t), store.count_by_type(t) )); } html.push_str("
    TypeCount
    {t}{}
    {}{}
    "); + // Status breakdown + let mut status_counts: BTreeMap = BTreeMap::new(); + for a in store.iter() { + let s = a.status.as_deref().unwrap_or("unknown"); + *status_counts.entry(s.to_string()).or_default() += 1; + } + let total_artifacts = store.len().max(1); + html.push_str("

    Status Distribution

    "); + for (status, count) in &status_counts { + let pct = (*count as f64 / total_artifacts as f64) * 100.0; + let bar_color = match status.as_str() { + "approved" => "#15713a", + "draft" => "#b8860b", + "obsolete" => "#c62828", + "unknown" => "#9898a6", + _ => "#3a86ff", + }; + html.push_str(&format!( + "
    \ +
    {}
    \ +
    \ +
    \ +
    \ +
    {count}
    \ +
    ", + html_escape(status), + )); + } + html.push_str("
    "); + // Orphans if !orphans.is_empty() { html.push_str("

    Orphan Artifacts (no links)

    "); @@ -877,8 +941,15 @@ async fn artifacts_list(State(state): State>) -> Html { artifacts.sort_by(|a, b| a.id.cmp(&b.id)); let mut html = String::from("

    Artifacts

    "); + // Client-side filter input + html.push_str("
    \ + \ + \ +
    "); html.push_str( - "
    ID
    ", + "
    IDTypeTitleStatusLinks
    ", ); for a in &artifacts { @@ -891,13 +962,13 @@ async fn artifacts_list(State(state): State>) -> Html { }; html.push_str(&format!( "\ - \ + \ \ \ ", html_escape(&a.id), html_escape(&a.id), - html_escape(&a.artifact_type), + badge_for_type(&a.artifact_type), html_escape(&a.title), status_badge, a.links.len() @@ -909,6 +980,17 @@ async fn artifacts_list(State(state): State>) -> Html { "

    {} artifacts total

    ", artifacts.len() )); + // Inline filter script + html.push_str( + "", + ); Html(html) } @@ -928,9 +1010,9 @@ async fn artifact_detail( }; let mut html = format!( - "

    {}

    {}

    ", + "

    {}

    {}

    ", html_escape(&artifact.id), - html_escape(&artifact.artifact_type) + badge_for_type(&artifact.artifact_type) ); html.push_str("
    "); @@ -1500,15 +1582,18 @@ async fn validate_view(State(state): State>) -> Html { let mut html = String::from("

    Validation Results

    "); - // Summary - let overall = if errors > 0 { - "FAIL" + // Colored summary bar + let total_issues = errors + warnings + infos; + if total_issues == 0 { + html.push_str("
    All checks passed
    "); } else { - "PASS" - }; - html.push_str(&format!( - "

    Status: {overall} — {errors} errors, {warnings} warnings, {infos} info

    " - )); + html.push_str(&format!( + "
    {total_issues} issue{} found — {errors} error{}, {warnings} warning{}, {infos} info
    ", + if total_issues != 1 { "s" } else { "" }, + if errors != 1 { "s" } else { "" }, + if warnings != 1 { "s" } else { "" }, + )); + } if diagnostics.is_empty() { html.push_str("

    No issues found.

    "); @@ -1747,7 +1832,7 @@ async fn coverage_view(State(state): State>) -> Html { html.push_str(&format!( "
    \ \ - \ + \ \ \ \ @@ -1759,7 +1844,7 @@ async fn coverage_view(State(state): State>) -> Html { ", html_escape(&entry.description), html_escape(&entry.rule_name), - html_escape(&entry.source_type), + badge_for_type(&entry.source_type), html_escape(&entry.link_type), dir_label, entry.covered, @@ -1829,13 +1914,13 @@ async fn documents_list(State(state): State>) -> Html { }; html.push_str(&format!( "\ - \ + \ \ \ ", html_escape(&doc.id), html_escape(&doc.id), - html_escape(&doc.doc_type), + badge_for_type(&doc.doc_type), html_escape(&doc.title), status_badge, doc.references.len(), @@ -1872,10 +1957,7 @@ async fn document_detail( html.push_str(&format!("

    {}

    ", html_escape(&doc.title))); html.push_str("
    "); - html.push_str(&format!( - "{}", - html_escape(&doc.doc_type) - )); + html.push_str(&badge_for_type(&doc.doc_type)); if let Some(status) = &doc.status { let badge_class = match status.as_str() { "approved" => "badge-ok", @@ -1949,12 +2031,12 @@ async fn document_detail( let status = artifact.status.as_deref().unwrap_or("-"); html.push_str(&format!( "
    \ - \ + \ \ ", html_escape(&artifact.id), html_escape(&artifact.id), - html_escape(&artifact.artifact_type), + badge_for_type(&artifact.artifact_type), html_escape(&artifact.title), html_escape(status), )); From 9622f67fb9994fc76ffb2258212f5c442c70886e Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 8 Mar 2026 18:22:13 +0100 Subject: [PATCH 7/7] Add test results model, verification/results views, markdown rendering, project selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rivet-core: add results module (TestStatus, TestRun, ResultStore, load_results) - rivet-core: add results field to ProjectConfig for test run YAML directory - rivet-core: enhance markdown renderer with code blocks, ordered lists, blockquotes, inline code, and markdown links - rivet-cli: add verification dashboard (/verification) showing req→verifier mapping with test steps, method badges, and latest result dots - rivet-cli: add results dashboard (/results, /results/{run_id}) with run history, pass rate stats, and per-artifact result tables - rivet-cli: add project overview with coverage summary, test results, and quick links on the landing page - rivet-cli: add project selector with sibling discovery (examples/ and peers) shown as a dropdown in the context bar - rivet-cli: add load_project_full() for serve command to load docs + results - examples/aspice: add test result YAML files (run-001, run-002) Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + examples/aspice/results/run-001.yaml | 26 + examples/aspice/results/run-002.yaml | 38 ++ examples/aspice/rivet.yaml | 2 + rivet-cli/src/main.rs | 82 ++- rivet-cli/src/serve.rs | 977 ++++++++++++++++++++++++++- rivet-core/Cargo.toml | 1 + rivet-core/src/document.rs | 229 ++++++- rivet-core/src/lib.rs | 1 + rivet-core/src/model.rs | 3 + rivet-core/src/results.rs | 475 +++++++++++++ 11 files changed, 1796 insertions(+), 39 deletions(-) create mode 100644 examples/aspice/results/run-001.yaml create mode 100644 examples/aspice/results/run-002.yaml create mode 100644 rivet-core/src/results.rs diff --git a/Cargo.lock b/Cargo.lock index 53b07c3..94bccc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,6 +2281,7 @@ dependencies = [ name = "rivet-core" version = "0.1.0" dependencies = [ + "anyhow", "criterion", "log", "petgraph", diff --git a/examples/aspice/results/run-001.yaml b/examples/aspice/results/run-001.yaml new file mode 100644 index 0000000..8537b8b --- /dev/null +++ b/examples/aspice/results/run-001.yaml @@ -0,0 +1,26 @@ +run: + id: run-2026-03-01 + timestamp: "2026-03-01T09:15:00Z" + source: "CI pipeline #18" + environment: "HIL bench A" + commit: "a1b2c3d" +results: + - artifact: UVER-1 + status: pass + duration: "1.2s" + - artifact: UVER-2 + status: pass + duration: "2.8s" + - artifact: SWINTVER-1 + status: pass + duration: "4.5s" + - artifact: SWINTVER-2 + status: fail + message: "ABS activation latency exceeded 3 cycle threshold on ice surface" + duration: "6.1s" + - artifact: SWVER-1 + status: pass + duration: "12.3s" + - artifact: SWVER-2 + status: pass + duration: "15.7s" diff --git a/examples/aspice/results/run-002.yaml b/examples/aspice/results/run-002.yaml new file mode 100644 index 0000000..764a22b --- /dev/null +++ b/examples/aspice/results/run-002.yaml @@ -0,0 +1,38 @@ +run: + id: run-2026-03-05 + timestamp: "2026-03-05T14:30:00Z" + source: "CI pipeline #24" + environment: "HIL bench A" + commit: "e4f5g6h" +results: + - artifact: UVER-1 + status: pass + duration: "1.1s" + - artifact: UVER-2 + status: pass + duration: "2.6s" + - artifact: SWINTVER-1 + status: pass + duration: "4.2s" + - artifact: SWINTVER-2 + status: pass + duration: "5.8s" + message: "Fixed: ABS cycle threshold tuning resolved" + - artifact: SWVER-1 + status: pass + duration: "11.9s" + - artifact: SWVER-2 + status: pass + duration: "14.3s" + - artifact: SYSINTVER-1 + status: pass + duration: "22.1s" + - artifact: SYSINTVER-2 + status: skip + message: "Vehicle integration bench unavailable" + - artifact: SYSVER-1 + status: pass + duration: "45.0s" + - artifact: SYSVER-2 + status: blocked + message: "Proving ground access pending weather clearance" diff --git a/examples/aspice/rivet.yaml b/examples/aspice/rivet.yaml index cdf760c..726188e 100644 --- a/examples/aspice/rivet.yaml +++ b/examples/aspice/rivet.yaml @@ -12,3 +12,5 @@ sources: docs: - docs + +results: results diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 45fb16f..f3f1528 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -9,6 +9,7 @@ use rivet_core::diff::{ArtifactDiff, DiagnosticDiff}; use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; use rivet_core::matrix::{self, Direction}; +use rivet_core::results::{self, ResultStore}; use rivet_core::schema::Severity; use rivet_core::store::Store; use rivet_core::validate; @@ -204,9 +205,19 @@ fn run(cli: Cli) -> Result { Command::Export { format, output } => cmd_export(&cli, format, output.as_deref()), Command::Serve { port } => { let port = *port; - let (store, schema, graph, doc_store) = load_project_with_docs(&cli)?; + let (store, schema, graph, doc_store, result_store, project_name, project_path) = + load_project_full(&cli)?; let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; - rt.block_on(serve::run(store, schema, graph, doc_store, port))?; + rt.block_on(serve::run( + store, + schema, + graph, + doc_store, + result_store, + project_name, + project_path, + port, + ))?; Ok(true) } #[cfg(feature = "wasm")] @@ -878,6 +889,73 @@ fn load_project_with_docs( Ok((store, schema, graph, doc_store)) } +fn load_project_full( + cli: &Cli, +) -> Result<( + Store, + rivet_core::schema::Schema, + LinkGraph, + DocumentStore, + ResultStore, + String, + PathBuf, +)> { + let config_path = cli.project.join("rivet.yaml"); + let config = rivet_core::load_project_config(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + + let schemas_dir = resolve_schemas_dir(cli); + let schema = rivet_core::load_schemas(&config.project.schemas, &schemas_dir) + .context("loading schemas")?; + + let mut store = Store::new(); + for source in &config.sources { + let artifacts = rivet_core::load_artifacts(source, &cli.project) + .with_context(|| format!("loading source '{}'", source.path))?; + for artifact in artifacts { + store.upsert(artifact); + } + } + + let graph = LinkGraph::build(&store, &schema); + + // Load documents + let mut doc_store = DocumentStore::new(); + for docs_path in &config.docs { + let dir = cli.project.join(docs_path); + let docs = document::load_documents(&dir) + .with_context(|| format!("loading docs from '{docs_path}'"))?; + for doc in docs { + doc_store.insert(doc); + } + } + + // Load test results + let mut result_store = ResultStore::new(); + if let Some(ref results_path) = config.results { + let dir = cli.project.join(results_path); + let runs = results::load_results(&dir) + .with_context(|| format!("loading results from '{results_path}'"))?; + for run in runs { + result_store.insert(run); + } + } + + let project_name = config.project.name.clone(); + let project_path = std::fs::canonicalize(&cli.project) + .unwrap_or_else(|_| cli.project.clone()); + + Ok(( + store, + schema, + graph, + doc_store, + result_store, + project_name, + project_path, + )) +} + fn print_stats(store: &Store) { println!("Artifact summary:"); let mut types: Vec<&str> = store.types().collect(); diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index a821d34..21b0c90 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; @@ -16,16 +17,148 @@ use rivet_core::coverage; use rivet_core::document::{self, DocumentStore}; use rivet_core::links::LinkGraph; use rivet_core::matrix::{self, Direction}; +use rivet_core::results::ResultStore; use rivet_core::schema::{Schema, Severity}; use rivet_core::store::Store; use rivet_core::validate; +// ── Repository context ────────────────────────────────────────────────── + +/// Git repository status captured at load time. +struct GitInfo { + branch: String, + commit_short: String, + is_dirty: bool, + dirty_count: usize, +} + +/// A discovered sibling project (example or peer). +struct SiblingProject { + name: String, + rel_path: String, +} + +/// Project context shown in the dashboard header. +struct RepoContext { + project_name: String, + project_path: String, + git: Option, + loaded_at: String, + siblings: Vec, + port: u16, +} + +fn capture_git_info(project_path: &std::path::Path) -> Option { + let branch = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?; + + let commit_short = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(project_path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + let porcelain = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(project_path) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let dirty_count = porcelain.lines().filter(|l| !l.is_empty()).count(); + + Some(GitInfo { + branch, + commit_short, + is_dirty: dirty_count > 0, + dirty_count, + }) +} + +/// Discover other rivet projects (examples/ and peer directories). +fn discover_siblings(project_path: &std::path::Path) -> Vec { + let mut siblings = Vec::new(); + + // Check examples/ subdirectory + let examples_dir = project_path.join("examples"); + if examples_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(&examples_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.join("rivet.yaml").exists() { + if let Some(name) = p.file_name().and_then(|n| n.to_str()) { + siblings.push(SiblingProject { + name: name.to_string(), + rel_path: format!("examples/{name}"), + }); + } + } + } + } + } + + // If inside examples/, offer root project and peers + if let Some(parent) = project_path.parent() { + if parent.file_name().and_then(|n| n.to_str()) == Some("examples") { + if let Some(root) = parent.parent() { + if root.join("rivet.yaml").exists() { + if let Ok(cfg) = std::fs::read_to_string(root.join("rivet.yaml")) { + let root_name = cfg + .lines() + .find(|l| l.trim().starts_with("name:")) + .map(|l| l.trim().trim_start_matches("name:").trim().to_string()) + .unwrap_or_else(|| { + root.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("root") + .to_string() + }); + siblings.push(SiblingProject { + name: root_name, + rel_path: root.display().to_string(), + }); + } + } + // Peer examples + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let p = entry.path(); + if p != project_path && p.join("rivet.yaml").exists() { + if let Some(name) = p.file_name().and_then(|n| n.to_str()) { + siblings.push(SiblingProject { + name: name.to_string(), + rel_path: p.display().to_string(), + }); + } + } + } + } + } + } + } + + siblings.sort_by(|a, b| a.name.cmp(&b.name)); + siblings +} + /// Shared application state loaded once at startup. struct AppState { store: Store, schema: Schema, graph: LinkGraph, doc_store: DocumentStore, + result_store: ResultStore, + context: RepoContext, } /// Start the axum HTTP server on the given port. @@ -34,13 +167,36 @@ pub async fn run( schema: Schema, graph: LinkGraph, doc_store: DocumentStore, + result_store: ResultStore, + project_name: String, + project_path: PathBuf, port: u16, ) -> Result<()> { + let git = capture_git_info(&project_path); + let loaded_at = std::process::Command::new("date") + .arg("+%H:%M:%S") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".into()); + let siblings = discover_siblings(&project_path); + let context = RepoContext { + project_name, + project_path: project_path.display().to_string(), + git, + loaded_at, + siblings, + port, + }; + let state = Arc::new(AppState { store, schema, graph, doc_store, + result_store, + context, }); let app = Router::new() @@ -56,6 +212,9 @@ pub async fn run( .route("/documents", get(documents_list)) .route("/documents/{id}", get(document_detail)) .route("/search", get(search_view)) + .route("/verification", get(verification_view)) + .route("/results", get(results_view)) + .route("/results/{run_id}", get(result_detail)) .with_state(state); let addr = format!("0.0.0.0:{port}"); @@ -311,10 +470,66 @@ nav a:hover .nav-icon,nav a.active .nav-icon{opacity:.9} .nav-badge-error{background:rgba(220,53,69,.2);color:#ff6b7a} nav .nav-divider{height:1px;background:rgba(255,255,255,.06);margin:.75rem .75rem} +/* ── Context bar ─────────────────────────────────────────────── */ +.context-bar{display:flex;align-items:center;gap:.75rem;padding:.5rem 1rem;margin:-2.5rem -3rem 1.5rem; + background:var(--surface);border-bottom:1px solid var(--border);font-size:.78rem;color:var(--text-secondary); + flex-wrap:wrap} +.context-bar .ctx-project{font-weight:700;color:var(--text);font-size:.82rem} +.context-bar .ctx-sep{opacity:.25} +.context-bar .ctx-git{font-family:var(--mono);font-size:.72rem;padding:.15rem .4rem;border-radius:4px; + background:rgba(58,134,255,.08);color:var(--accent)} +.context-bar .ctx-dirty{font-family:var(--mono);font-size:.68rem;padding:.15rem .4rem;border-radius:4px; + background:rgba(220,53,69,.1);color:#c62828} +.context-bar .ctx-clean{font-family:var(--mono);font-size:.68rem;padding:.15rem .4rem;border-radius:4px; + background:rgba(21,113,58,.1);color:#15713a} +.context-bar .ctx-time{margin-left:auto;opacity:.6} +.ctx-switcher{position:relative;display:inline-flex;align-items:center} +.ctx-switcher-details{position:relative} +.ctx-switcher-details summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center; + padding:.15rem .35rem;border-radius:4px;opacity:.5;transition:opacity .15s} +.ctx-switcher-details summary:hover{opacity:1;background:rgba(255,255,255,.06)} +.ctx-switcher-details summary::-webkit-details-marker{display:none} +.ctx-switcher-dropdown{position:absolute;top:100%;left:0;z-index:100;margin-top:.35rem; + background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm); + padding:.5rem;min-width:280px;box-shadow:0 8px 24px rgba(0,0,0,.35)} +.ctx-switcher-item{padding:.5rem .65rem;border-radius:4px} +.ctx-switcher-item:hover{background:rgba(255,255,255,.04)} +.ctx-switcher-item .ctx-switcher-name{display:block;font-weight:600;font-size:.8rem;color:var(--text);margin-bottom:.2rem} +.ctx-switcher-item .ctx-switcher-cmd{display:block;font-size:.7rem;color:var(--text-secondary); + padding:.2rem .4rem;background:rgba(255,255,255,.04);border-radius:3px; + font-family:var(--mono);user-select:all;cursor:text} + /* ── Footer ──────────────────────────────────────────────────── */ .footer{padding:2rem 0 1rem;text-align:center;font-size:.75rem;color:var(--text-secondary); border-top:1px solid var(--border);margin-top:3rem} +/* ── Verification ────────────────────────────────────────────── */ +.ver-level{margin-bottom:1.5rem} +.ver-level-header{display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem} +.ver-level-title{font-size:1rem;font-weight:600;color:var(--text)} +.ver-level-arrow{color:var(--text-secondary);font-size:.85rem} +details.ver-row>summary{cursor:pointer;list-style:none;padding:.6rem .875rem;border-bottom:1px solid var(--border); + display:flex;align-items:center;gap:.75rem;transition:background var(--transition)} +details.ver-row>summary::-webkit-details-marker{display:none} +details.ver-row>summary:hover{background:rgba(58,134,255,.04)} +details.ver-row[open]>summary{background:rgba(58,134,255,.04);border-bottom-color:var(--accent)} +details.ver-row>.ver-detail{padding:1rem 1.5rem;background:rgba(0,0,0,.01);border-bottom:1px solid var(--border)} +.ver-chevron{transition:transform var(--transition);display:inline-flex;opacity:.4} +details.ver-row[open] .ver-chevron{transform:rotate(90deg)} +.ver-steps{width:100%;border-collapse:collapse;font-size:.85rem;margin-top:.5rem} +.ver-steps th{text-align:left;font-weight:600;font-size:.72rem;text-transform:uppercase; + letter-spacing:.04em;color:var(--text-secondary);padding:.4rem .5rem;border-bottom:1px solid var(--border)} +.ver-steps td{padding:.4rem .5rem;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:top} +.method-badge{display:inline-flex;padding:.15rem .5rem;border-radius:4px;font-size:.72rem;font-weight:600; + background:#e8f4fd;color:#0c5a82} + +/* ── Results ─────────────────────────────────────────────────── */ +.result-pass{color:#15713a}.result-fail{color:#c62828}.result-skip{color:#6e6e73} +.result-error{color:#e67e22}.result-blocked{color:#8b6914} +.result-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.35rem} +.result-dot-pass{background:#15713a}.result-dot-fail{background:#c62828} +.result-dot-skip{background:#c5c5cd}.result-dot-error{background:#e67e22}.result-dot-blocked{background:#b8860b} + /* ── Detail actions ──────────────────────────────────────────── */ .detail-actions{display:flex;gap:.75rem;align-items:center;margin-top:1rem} .btn{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem 1rem;border-radius:var(--radius-sm); @@ -719,28 +934,96 @@ const SEARCH_JS: &str = r#" // ── Layout ─────────────────────────────────────────────────────────────── -struct NavInfo { - artifact_count: usize, - error_count: usize, - doc_count: usize, -} - -fn page_layout(content: &str, nav: &NavInfo) -> Html { - let artifact_count = nav.artifact_count; - let error_badge = if nav.error_count > 0 { +fn page_layout(content: &str, state: &AppState) -> Html { + let artifact_count = state.store.len(); + let diagnostics = validate::validate(&state.store, &state.schema, &state.graph); + let error_count = diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .count(); + let error_badge = if error_count > 0 { format!( - "{}", - nav.error_count + "{error_count}" ) } else { "OK".to_string() }; - let doc_badge = if nav.doc_count > 0 { - format!("{}", nav.doc_count) + let doc_badge = if !state.doc_store.is_empty() { + format!( + "{}", + state.doc_store.len() + ) + } else { + String::new() + }; + let result_badge = if !state.result_store.is_empty() { + format!( + "{}", + state.result_store.len() + ) } else { String::new() }; let version = env!("CARGO_PKG_VERSION"); + + // Context bar + let ctx = &state.context; + let git_html = if let Some(ref git) = ctx.git { + let status = if git.is_dirty { + format!( + "{} uncommitted", + git.dirty_count + ) + } else { + "clean".to_string() + }; + format!( + "/\ + {branch}@{commit}\ + {status}", + branch = html_escape(&git.branch), + commit = html_escape(&git.commit_short), + ) + } else { + String::new() + }; + // Project switcher: show siblings as a dropdown if available + let switcher_html = if ctx.siblings.is_empty() { + String::new() + } else { + let mut s = String::from( + "\ +
    \ + \ +
    ", + ); + for sib in &ctx.siblings { + s.push_str(&format!( + "
    \ + {}\ + rivet -p {} serve -P {}\ +
    ", + html_escape(&sib.name), + html_escape(&sib.rel_path), + ctx.port, + )); + } + s.push_str("
    "); + s + }; + let context_bar = format!( + "
    \ + {project}{switcher_html}\ + /\ + {path}\ + {git_html}\ + Loaded {loaded_at}\ +
    ", + project = html_escape(&ctx.project_name), + path = html_escape(&ctx.project_path), + loaded_at = html_escape(&ctx.loaded_at), + ); Html(format!( r##" @@ -768,6 +1051,9 @@ fn page_layout(content: &str, nav: &NavInfo) -> Html {
  • Coverage
  • Graph
  • Documents{doc_badge}
  • + +
  • Verification
  • +
  • Results{result_badge}
  • +{context_bar} {content}
    @@ -801,21 +1088,7 @@ fn page_layout(content: &str, nav: &NavInfo) -> Html { async fn index(State(state): State>) -> Html { let inner = stats_partial(&state); - let nav = make_nav_info(&state); - page_layout(&inner, &nav) -} - -fn make_nav_info(state: &AppState) -> NavInfo { - let diagnostics = validate::validate(&state.store, &state.schema, &state.graph); - let error_count = diagnostics - .iter() - .filter(|d| d.severity == Severity::Error) - .count(); - NavInfo { - artifact_count: state.store.len(), - error_count, - doc_count: state.doc_store.len(), - } + page_layout(&inner, &state) } async fn stats_view(State(state): State>) -> Html { @@ -841,7 +1114,16 @@ fn stats_partial(state: &AppState) -> String { .filter(|d| d.severity == Severity::Warning) .count(); - let mut html = String::from("

    Dashboard

    "); + // Project header + let mut html = format!( + "
    \ +

    Project Overview

    \ +

    {} — {} artifact types, {} traceability rules

    \ +
    ", + html_escape(&state.context.project_name), + types.len(), + state.schema.traceability_rules.len(), + ); // Summary cards with colored accents html.push_str("
    "); @@ -869,12 +1151,10 @@ fn stats_partial(state: &AppState) -> String { "
    {}
    Broken Links
    ", graph.broken.len() )); - if !doc_store.is_empty() { - html.push_str(&format!( - "
    {}
    Documents
    ", - doc_store.len() - )); - } + html.push_str(&format!( + "
    {}
    Documents
    ", + doc_store.len() + )); html.push_str("
    "); // By-type table @@ -929,6 +1209,135 @@ fn stats_partial(state: &AppState) -> String { html.push_str("
    IDTypeTitleStatusLinks
    {}{}{}{}{}{}
    {}{}{}{}{}{}/{} ({:.1}%)
    {}{}{}{}{}{}
    {}{}{}{}{}
    "); } + // ── Coverage summary card ──────────────────────────────────────── + let cov_report = coverage::compute_coverage(store, &state.schema, graph); + if !cov_report.entries.is_empty() { + let overall = cov_report.overall_coverage(); + let cov_color = if overall >= 80.0 { + "#15713a" + } else if overall >= 50.0 { + "#b8860b" + } else { + "#c62828" + }; + 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(); + html.push_str(&format!( + "
    \ +

    Traceability Coverage

    \ +
    \ +
    {overall:.0}%
    \ +
    \ +
    \ +
    \ +
    \ +
    \ + {total_covered} / {total_items} artifacts covered across {} rules\ +
    \ +
    \ +
    \ + \ + View full coverage report →\ +
    ", + cov_report.entries.len(), + )); + } + + // ── Test results summary ───────────────────────────────────────── + if !state.result_store.is_empty() { + let summary = state.result_store.summary(); + let rate = summary.pass_rate(); + let rate_color = if rate >= 80.0 { + "#15713a" + } else if rate >= 50.0 { + "#b8860b" + } else { + "#c62828" + }; + html.push_str("

    Test Results

    "); + html.push_str(&format!( + "
    \ +
    {rate:.0}%
    \ +
    \ +
    \ +
    \ +
    \ +
    \ +
    " + )); + html.push_str("
    "); + html.push_str(&format!( + "{} runs\ + {} passed\ + {} failed", + summary.total_runs, summary.pass_count, summary.fail_count, + )); + if summary.skip_count > 0 { + html.push_str(&format!( + "{} skipped", + summary.skip_count, + )); + } + if summary.blocked_count > 0 { + html.push_str(&format!( + "{} blocked", + summary.blocked_count, + )); + } + html.push_str("
    "); + html.push_str( + "\ + View all test runs →", + ); + html.push_str("
    "); + } + + // ── Quick links ────────────────────────────────────────────────── + // Count verifiable types for the verification link badge + let ver_count = { + let mut count = 0usize; + for rule in &state.schema.traceability_rules { + if rule.required_backlink.as_deref() == Some("verifies") { + count += store.by_type(&rule.source_type).len(); + } + } + count + }; + + html.push_str( + "
    \ +

    Quick Links

    \ +
    ", + ); + html.push_str(&format!( + "\ +
    Verification
    \ +
    {ver_count} requirements
    \ +
    ", + )); + html.push_str(&format!( + "\ +
    Documents
    \ +
    {} loaded
    \ +
    ", + doc_store.len(), + )); + html.push_str( + "\ +
    Traceability Graph
    \ +
    Full link graph
    \ +
    ", + ); + html.push_str("
    "); + html } @@ -2311,6 +2720,504 @@ fn highlight_match(text: &str, query: &str) -> String { result } +// ── Verification ───────────────────────────────────────────────────────── + +async fn verification_view(State(state): State>) -> Html { + let store = &state.store; + let graph = &state.graph; + let schema = &state.schema; + + // Find types that need verification (have required-backlink: verifies rules) + let mut verifiable_types: Vec<(String, String)> = Vec::new(); // (source_type, rule_name) + for rule in &schema.traceability_rules { + if rule.required_backlink.as_deref() == Some("verifies") { + verifiable_types.push((rule.source_type.clone(), rule.name.clone())); + } + } + + // Also find types that have forward `verifies` links (the verifiers themselves) + // to auto-discover if no rules match + if verifiable_types.is_empty() { + // Fallback: find all artifact types that have backlinks of type "verifies" + let mut seen = std::collections::HashSet::new(); + for artifact in store.iter() { + let backlinks = graph.backlinks_to(&artifact.id); + for bl in backlinks { + if bl.link_type == "verifies" && seen.insert(artifact.artifact_type.clone()) { + verifiable_types + .push((artifact.artifact_type.clone(), "verifies".to_string())); + } + } + } + } + + let mut html = String::from("

    Verification

    "); + + if verifiable_types.is_empty() { + html.push_str("

    No verification traceability rules found in the schema. \ + Add required-backlink: verifies rules to your schema to enable the verification dashboard.

    "); + return Html(html); + } + + // Compute stats + let mut total_reqs = 0usize; + let mut verified_reqs = 0usize; + + // Group by verifiable type + for (source_type, _rule_name) in &verifiable_types { + let source_ids = store.by_type(source_type); + if source_ids.is_empty() { + continue; + } + + total_reqs += source_ids.len(); + + // Collect requirement → verifier mapping + struct ReqRow { + id: String, + title: String, + status: String, + verifiers: Vec, + } + struct VerifierInfo { + id: String, + title: String, + artifact_type: String, + method: String, + steps: Vec, + latest_result: Option<(String, rivet_core::results::TestStatus)>, + } + struct StepInfo { + step: String, + action: String, + expected: String, + } + + let mut rows: Vec = Vec::new(); + + for req_id in source_ids { + let req = store.get(req_id).unwrap(); + let backlinks = graph.backlinks_to(req_id); + let ver_links: Vec<_> = backlinks + .iter() + .filter(|bl| bl.link_type == "verifies") + .collect(); + + if !ver_links.is_empty() { + verified_reqs += 1; + } + + let mut verifiers = Vec::new(); + for bl in &ver_links { + if let Some(ver_artifact) = store.get(&bl.source) { + let method = ver_artifact + .fields + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("unspecified") + .to_string(); + + let steps = ver_artifact + .fields + .get("steps") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .map(|s| { + let step = s + .get("step") + .map(|v| { + if let Some(n) = v.as_u64() { + n.to_string() + } else if let Some(s) = v.as_str() { + s.to_string() + } else { + format!("{v:?}") + } + }) + .unwrap_or_default(); + let action = s + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let expected = s + .get("expected") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + StepInfo { + step, + action, + expected, + } + }) + .collect() + }) + .unwrap_or_default(); + + // Look up latest test result + let latest_result = state + .result_store + .latest_for(&bl.source) + .map(|(_run, r)| (r.status.to_string(), r.status.clone())); + + verifiers.push(VerifierInfo { + id: ver_artifact.id.clone(), + title: ver_artifact.title.clone(), + artifact_type: ver_artifact.artifact_type.clone(), + method, + steps, + latest_result, + }); + } + } + + rows.push(ReqRow { + id: req.id.clone(), + title: req.title.clone(), + status: req.status.as_deref().unwrap_or("-").to_string(), + verifiers, + }); + } + + rows.sort_by(|a, b| a.id.cmp(&b.id)); + + // Render this type's section + let type_verified = rows.iter().filter(|r| !r.verifiers.is_empty()).count(); + let type_total = rows.len(); + let pct = if type_total > 0 { + (type_verified as f64 / type_total as f64) * 100.0 + } else { + 100.0 + }; + + html.push_str("
    "); + html.push_str(&format!( + "
    \ + {} \ + verified by \ + {type_verified}/{type_total} ({pct:.0}%)
    ", + badge_for_type(source_type), + )); + + for row in &rows { + let ver_count = row.verifiers.len(); + let has_verifiers = ver_count > 0; + let coverage_badge = if has_verifiers { + format!( + "{ver_count} verifier{}", + if ver_count > 1 { "s" } else { "" } + ) + } else { + "unverified".to_string() + }; + + html.push_str("
    "); + html.push_str(&format!( + "\ + {id}\ + {title}\ + {status}\ + {coverage_badge}", + id = html_escape(&row.id), + title = html_escape(&row.title), + status = html_escape(&row.status), + )); + + // Show latest result dots for verifiers + for v in &row.verifiers { + if let Some((_, ref status)) = v.latest_result { + let dot_class = match status { + rivet_core::results::TestStatus::Pass => "result-dot-pass", + rivet_core::results::TestStatus::Fail => "result-dot-fail", + rivet_core::results::TestStatus::Skip => "result-dot-skip", + rivet_core::results::TestStatus::Error => "result-dot-error", + rivet_core::results::TestStatus::Blocked => "result-dot-blocked", + }; + html.push_str(&format!( + "", + html_escape(&v.id), + status + )); + } + } + + html.push_str(""); + + if has_verifiers { + html.push_str("
    "); + for v in &row.verifiers { + html.push_str(&format!( + "

    \ + {id} \ + {type_badge} \ + {method} \ + — {title}", + id = html_escape(&v.id), + type_badge = badge_for_type(&v.artifact_type), + method = html_escape(&v.method), + title = html_escape(&v.title), + )); + if let Some((ref status_str, _)) = v.latest_result { + html.push_str(&format!( + " {status_str}", + cls = match status_str.as_str() { + "pass" => "ok", + "fail" | "error" => "error", + "skip" | "blocked" => "warn", + _ => "info", + }, + )); + } + html.push_str("

    "); + + if !v.steps.is_empty() { + html.push_str( + "\ + \ + ", + ); + for s in &v.steps { + html.push_str(&format!( + "", + html_escape(&s.step), + html_escape(&s.action), + html_escape(&s.expected), + )); + } + html.push_str("
    #ActionExpected
    {}{}{}
    "); + } + } + html.push_str("
    "); + } + + html.push_str("
    "); + } + + html.push_str("
    "); + } + + // Summary stats + let ver_pct = if total_reqs > 0 { + (verified_reqs as f64 / total_reqs as f64) * 100.0 + } else { + 100.0 + }; + let summary = format!( + "
    \ +
    {total_reqs}
    Requirements
    \ +
    {verified_reqs}
    Verified
    \ +
    {}
    Unverified
    \ +
    {ver_pct:.0}%
    Coverage
    \ +
    ", + total_reqs - verified_reqs, + ); + + // Insert summary before the level cards + html = format!( + "

    Verification

    {summary}{}", + &html["

    Verification

    ".len()..] + ); + + Html(html) +} + +// ── Results ────────────────────────────────────────────────────────────── + +async fn results_view(State(state): State>) -> Html { + let result_store = &state.result_store; + + let mut html = String::from("

    Test Results

    "); + + if result_store.is_empty() { + html.push_str("

    No test results loaded. Add result YAML files to a results/ directory and reference it in rivet.yaml:

    \ +
    results: results
    \ +

    Each result file contains a run: metadata block and a results: list with per-artifact pass/fail/skip status.

    "); + return Html(html); + } + + let summary = result_store.summary(); + + // Stats + html.push_str("
    "); + html.push_str(&format!( + "
    {}
    Total Runs
    ", + summary.total_runs + )); + html.push_str(&format!( + "
    {:.0}%
    Pass Rate
    ", + summary.pass_rate() + )); + html.push_str(&format!( + "
    {}
    Passed
    ", + summary.pass_count + )); + html.push_str(&format!( + "
    {}
    Failed
    ", + summary.fail_count + )); + if summary.skip_count > 0 { + html.push_str(&format!( + "
    {}
    Skipped
    ", + summary.skip_count + )); + } + if summary.blocked_count > 0 { + html.push_str(&format!( + "
    {}
    Blocked
    ", + summary.blocked_count + )); + } + html.push_str("
    "); + + // Run history table + html.push_str("

    Run History

    "); + html.push_str( + "\ + ", + ); + + for run in result_store.runs() { + let pass = run + .results + .iter() + .filter(|r| r.status.is_pass()) + .count(); + let fail = run + .results + .iter() + .filter(|r| r.status.is_fail()) + .count(); + let skip = run.results.len() - pass - fail; + let total = run.results.len(); + + let status_badge = if fail > 0 { + "FAIL" + } else { + "PASS" + }; + + html.push_str(&format!( + "\ + \ + \ + \ + \ + \ + \ + \ + \ + ", + id = html_escape(&run.run.id), + ts = html_escape(&run.run.timestamp), + src = run.run.source.as_deref().unwrap_or("-"), + env = run.run.environment.as_deref().unwrap_or("-"), + )); + } + + html.push_str("
    Run IDTimestampSourceEnvironmentPassFailSkipTotal
    {id} {status_badge}{ts}{src}{env}{pass}{fail}{skip}{total}
    "); + + Html(html) +} + +async fn result_detail( + State(state): State>, + Path(run_id): Path, +) -> Html { + let result_store = &state.result_store; + + let Some(run) = result_store.get_run(&run_id) else { + return Html(format!( + "

    Not Found

    Run {} does not exist.

    ", + html_escape(&run_id) + )); + }; + + let mut html = format!("

    Run: {}

    ", html_escape(&run.run.id)); + + // Metadata + html.push_str("
    "); + html.push_str(&format!( + "
    Timestamp
    {}
    ", + html_escape(&run.run.timestamp) + )); + if let Some(ref source) = run.run.source { + html.push_str(&format!( + "
    Source
    {}
    ", + html_escape(source) + )); + } + if let Some(ref env) = run.run.environment { + html.push_str(&format!( + "
    Environment
    {}
    ", + html_escape(env) + )); + } + if let Some(ref commit) = run.run.commit { + html.push_str(&format!( + "
    Commit
    {}
    ", + html_escape(commit) + )); + } + html.push_str("
    "); + + // Results table + html.push_str("

    Results

    "); + html.push_str( + "", + ); + + for result in &run.results { + let title = state + .store + .get(&result.artifact) + .map(|a| a.title.as_str()) + .unwrap_or("-"); + let (status_badge, status_class) = match result.status { + rivet_core::results::TestStatus::Pass => { + ("PASS", "") + } + rivet_core::results::TestStatus::Fail => { + ("FAIL", "result-fail") + } + rivet_core::results::TestStatus::Skip => { + ("SKIP", "") + } + rivet_core::results::TestStatus::Error => { + ("ERROR", "result-error") + } + rivet_core::results::TestStatus::Blocked => { + ("BLOCKED", "") + } + }; + + let duration = result.duration.as_deref().unwrap_or("-"); + let message = result.message.as_deref().unwrap_or(""); + + html.push_str(&format!( + "\ + \ + \ + \ + \ + \ + ", + aid = html_escape(&result.artifact), + title = html_escape(title), + msg = html_escape(message), + )); + } + + html.push_str("
    ArtifactTitleStatusDurationMessage
    {aid}{title}{status_badge}{duration}{msg}
    "); + + html.push_str( + "

    ← Back to results

    ", + ); + + Html(html) +} + // ── Helpers ────────────────────────────────────────────────────────────── fn html_escape(s: &str) -> String { diff --git a/rivet-core/Cargo.toml b/rivet-core/Cargo.toml index f7efcd7..5aa5de7 100644 --- a/rivet-core/Cargo.toml +++ b/rivet-core/Cargo.toml @@ -18,6 +18,7 @@ serde_yaml = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } petgraph = { workspace = true } +anyhow = { workspace = true } log = { workspace = true } quick-xml = { workspace = true } diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index b0d173b..4beb884 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -293,13 +293,59 @@ fn heading_level(line: &str) -> Option { pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> String { let mut html = String::with_capacity(doc.body.len() * 2); let mut in_list = false; + let mut in_ordered_list = false; let mut in_paragraph = false; let mut in_table = false; let mut table_header_done = false; + let mut in_code_block = false; + let mut code_block_lines: Vec = Vec::new(); + let mut in_blockquote = false; for line in doc.body.lines() { let trimmed = line.trim(); + // Code blocks must be handled first — content inside is literal. + if trimmed.starts_with("```") { + if in_code_block { + // Closing fence: emit the accumulated code block. + html.push_str("
    ");
    +                html.push_str(&code_block_lines.join("\n"));
    +                html.push_str("
    \n"); + code_block_lines.clear(); + in_code_block = false; + } else { + // Opening fence: close any open block-level element first. + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if in_list { + html.push_str("\n"); + in_list = false; + } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } + in_code_block = true; + } + continue; + } + + if in_code_block { + code_block_lines.push(html_escape(line)); + continue; + } + if trimmed.is_empty() { if in_paragraph { html.push_str("

    \n"); @@ -309,11 +355,19 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("\n"); in_list = false; } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } if in_table { html.push_str("\n"); in_table = false; table_header_done = false; } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } continue; } @@ -327,11 +381,19 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("\n"); in_list = false; } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } if in_table { html.push_str("\n"); in_table = false; table_header_done = false; } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } let text = &trimmed[level as usize + 1..]; let text = resolve_inline(text, &artifact_exists); html.push_str(&format!("{text}\n")); @@ -348,6 +410,14 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("\n"); in_list = false; } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } // Skip separator rows like |---|---| if is_table_separator(trimmed) { @@ -381,17 +451,53 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> continue; } - // List items + // Blockquotes + if let Some(bq_text) = trimmed.strip_prefix("> ") { + if in_paragraph { + html.push_str("

    \n"); + in_paragraph = false; + } + if in_list { + html.push_str("\n"); + in_list = false; + } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } + if !in_blockquote { + html.push_str("
    "); + in_blockquote = true; + } + let text = resolve_inline(bq_text, &artifact_exists); + html.push_str(&format!("

    {text}

    ")); + continue; + } + + // Unordered list items if trimmed.starts_with("- ") || trimmed.starts_with("* ") { if in_paragraph { html.push_str("

    \n"); in_paragraph = false; } + if in_ordered_list { + html.push_str("\n"); + in_ordered_list = false; + } if in_table { html.push_str("\n"); in_table = false; table_header_done = false; } + if in_blockquote { + html.push_str("
    \n"); + in_blockquote = false; + } if !in_list { html.push_str("
      \n"); in_list = true; @@ -401,16 +507,52 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> continue; } + // Ordered list items (e.g. "1. item") + if let Some(rest) = ordered_list_text(trimmed) { + if in_paragraph { + html.push_str("

      \n"); + in_paragraph = false; + } + if in_list { + html.push_str("
    \n"); + in_list = false; + } + if in_table { + html.push_str("\n"); + in_table = false; + table_header_done = false; + } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } + if !in_ordered_list { + html.push_str("
      \n"); + in_ordered_list = true; + } + let text = resolve_inline(rest, &artifact_exists); + html.push_str(&format!("
    1. {text}
    2. \n")); + continue; + } + // Regular text → paragraph if in_list { html.push_str("\n"); in_list = false; } + if in_ordered_list { + html.push_str("
    \n"); + in_ordered_list = false; + } if in_table { html.push_str("\n"); in_table = false; table_header_done = false; } + if in_blockquote { + html.push_str("\n"); + in_blockquote = false; + } if !in_paragraph { html.push_str("

    "); in_paragraph = true; @@ -426,9 +568,15 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> if in_list { html.push_str("\n"); } + if in_ordered_list { + html.push_str("\n"); + } if in_table { html.push_str("\n"); } + if in_blockquote { + html.push_str("\n"); + } html } @@ -440,12 +588,54 @@ fn is_table_separator(line: &str) -> bool { .all(|cell| cell.trim().chars().all(|c| c == '-' || c == ':')) } -/// Resolve inline formatting: `[[ID]]` links, **bold**, *italic*. +/// If the line is an ordered list item (e.g. `1. text`), return the text after the marker. +fn ordered_list_text(line: &str) -> Option<&str> { + let digit_end = line + .as_bytes() + .iter() + .position(|b| !b.is_ascii_digit())?; + if digit_end == 0 { + return None; + } + let rest = &line[digit_end..]; + rest.strip_prefix(". ") +} + +/// Resolve inline formatting: `[[ID]]` links, **bold**, *italic*, `code`, [text](url). fn resolve_inline(text: &str, artifact_exists: &impl Fn(&str) -> bool) -> String { let mut result = String::with_capacity(text.len() * 2); let mut chars = text.char_indices().peekable(); while let Some((i, ch)) = chars.next() { + // Inline code (backticks) — must come before bold/italic since content is literal. + if ch == '`' { + if let Some(end) = text[i + 1..].find('`') { + let inner = html_escape(&text[i + 1..i + 1 + end]); + result.push_str(&format!("{inner}")); + let skip_to = i + 1 + end + 1; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } + + // Markdown links [text](url) — must come before [[id]] artifact refs. + if ch == '[' && !text[i..].starts_with("[[") { + if let Some(link) = parse_markdown_link(&text[i..]) { + let text_part = html_escape(&link.text); + result.push_str(&format!( + "{text_part}", + href = html_escape(&link.url), + )); + let skip_to = i + link.total_len; + while chars.peek().is_some_and(|&(j, _)| j < skip_to) { + chars.next(); + } + continue; + } + } + if ch == '[' && text[i..].starts_with("[[") { // Find closing ]] if let Some(end) = text[i + 2..].find("]]") { @@ -512,6 +702,41 @@ fn html_escape(s: &str) -> String { .replace('"', """) } +/// Result of parsing a `[text](url)` markdown link. +struct MarkdownLink { + text: String, + url: String, + /// Total number of bytes consumed from the input (including `[`, `]`, `(`, `)`). + total_len: usize, +} + +/// Try to parse `[text](url)` at the start of `s`. +/// +/// Only allows `http://`, `https://`, and `#` URLs for safety (no `javascript:` etc.). +fn parse_markdown_link(s: &str) -> Option { + if !s.starts_with('[') { + return None; + } + let close_bracket = s[1..].find(']')?; + let text = &s[1..1 + close_bracket]; + let after_bracket = &s[1 + close_bracket + 1..]; + if !after_bracket.starts_with('(') { + return None; + } + let close_paren = after_bracket[1..].find(')')?; + let url = &after_bracket[1..1 + close_paren]; + // Safety check: only allow http, https, and fragment (#) URLs. + if !(url.starts_with("http://") || url.starts_with("https://") || url.starts_with('#')) { + return None; + } + let total_len = 1 + close_bracket + 1 + 1 + close_paren + 1; // [text](url) + Some(MarkdownLink { + text: text.to_string(), + url: url.to_string(), + total_len, + }) +} + // --------------------------------------------------------------------------- // Document store // --------------------------------------------------------------------------- diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index ba6b0dd..48e8052 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod model; pub mod oslc; pub mod query; pub mod reqif; +pub mod results; pub mod schema; pub mod store; pub mod validate; diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 2d980ec..2308f23 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -85,6 +85,9 @@ pub struct ProjectConfig { /// Directories containing markdown documents (with YAML frontmatter). #[serde(default)] pub docs: Vec, + /// Directory containing test result YAML files. + #[serde(default)] + pub results: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rivet-core/src/results.rs b/rivet-core/src/results.rs new file mode 100644 index 0000000..f6e37bf --- /dev/null +++ b/rivet-core/src/results.rs @@ -0,0 +1,475 @@ +//! Test run results model and loader. +//! +//! Results are stored as YAML files, each representing a single test run +//! with per-artifact pass/fail/skip results. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Outcome of a single test. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TestStatus { + Pass, + Fail, + Skip, + Error, + Blocked, +} + +impl TestStatus { + pub fn is_pass(&self) -> bool { + matches!(self, Self::Pass) + } + pub fn is_fail(&self) -> bool { + matches!(self, Self::Fail | Self::Error) + } +} + +impl std::fmt::Display for TestStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pass => write!(f, "pass"), + Self::Fail => write!(f, "fail"), + Self::Skip => write!(f, "skip"), + Self::Error => write!(f, "error"), + Self::Blocked => write!(f, "blocked"), + } + } +} + +/// A single test result for one artifact in a run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestResult { + /// The artifact ID this result is for (e.g., "UVER-1"). + pub artifact: String, + pub status: TestStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Metadata for a test run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunMetadata { + pub id: String, + pub timestamp: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, +} + +/// YAML file structure for a test run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestRunFile { + pub run: RunMetadata, + pub results: Vec, +} + +/// A loaded test run. +#[derive(Debug, Clone)] +pub struct TestRun { + pub run: RunMetadata, + pub results: Vec, + pub source_file: Option, +} + +/// Aggregate statistics for a result set. +#[derive(Debug, Clone, Default)] +pub struct ResultSummary { + pub total_runs: usize, + pub total_results: usize, + pub pass_count: usize, + pub fail_count: usize, + pub skip_count: usize, + pub error_count: usize, + pub blocked_count: usize, +} + +impl ResultSummary { + pub fn pass_rate(&self) -> f64 { + if self.total_results == 0 { + return 0.0; + } + (self.pass_count as f64 / self.total_results as f64) * 100.0 + } +} + +/// In-memory collection of test runs. +#[derive(Debug, Default)] +pub struct ResultStore { + runs: Vec, +} + +impl ResultStore { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, run: TestRun) { + self.runs.push(run); + // Keep sorted by timestamp descending (newest first) + self.runs + .sort_by(|a, b| b.run.timestamp.cmp(&a.run.timestamp)); + } + + pub fn is_empty(&self) -> bool { + self.runs.is_empty() + } + + pub fn len(&self) -> usize { + self.runs.len() + } + + /// All runs, sorted newest first. + pub fn runs(&self) -> &[TestRun] { + &self.runs + } + + /// Get a specific run by ID. + pub fn get_run(&self, run_id: &str) -> Option<&TestRun> { + self.runs.iter().find(|r| r.run.id == run_id) + } + + /// Latest result for a given artifact ID across all runs. + /// Returns the run metadata and the test result. + pub fn latest_for(&self, artifact_id: &str) -> Option<(&RunMetadata, &TestResult)> { + // runs are sorted newest first, so first match is latest + for run in &self.runs { + if let Some(result) = run.results.iter().find(|r| r.artifact == artifact_id) { + return Some((&run.run, result)); + } + } + None + } + + /// All results for a specific artifact across all runs (newest first). + pub fn history_for(&self, artifact_id: &str) -> Vec<(&RunMetadata, &TestResult)> { + self.runs + .iter() + .filter_map(|run| { + run.results + .iter() + .find(|r| r.artifact == artifact_id) + .map(|result| (&run.run, result)) + }) + .collect() + } + + /// Aggregate summary across all runs. + pub fn summary(&self) -> ResultSummary { + let mut s = ResultSummary { + total_runs: self.runs.len(), + ..Default::default() + }; + // Count from the latest run only for overall stats + if let Some(latest) = self.runs.first() { + for r in &latest.results { + s.total_results += 1; + match r.status { + TestStatus::Pass => s.pass_count += 1, + TestStatus::Fail => s.fail_count += 1, + TestStatus::Skip => s.skip_count += 1, + TestStatus::Error => s.error_count += 1, + TestStatus::Blocked => s.blocked_count += 1, + } + } + } + s + } +} + +/// Load all test run YAML files from a directory. +pub fn load_results(dir: &Path) -> anyhow::Result> { + let mut runs = Vec::new(); + + if !dir.exists() { + return Ok(runs); + } + + let mut entries: Vec<_> = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + let p = e.path(); + matches!(p.extension().and_then(|x| x.to_str()), Some("yaml" | "yml")) + }) + .collect(); + entries.sort_by_key(|e| e.path()); + + for entry in entries { + let path = entry.path(); + let content = std::fs::read_to_string(&path)?; + let file: TestRunFile = serde_yaml::from_str(&content) + .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; + runs.push(TestRun { + run: file.run, + results: file.results, + source_file: Some(path), + }); + } + + Ok(runs) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_run(id: &str, timestamp: &str, results: Vec) -> TestRun { + TestRun { + run: RunMetadata { + id: id.to_string(), + timestamp: timestamp.to_string(), + source: None, + environment: None, + commit: None, + }, + results, + source_file: None, + } + } + + fn make_result(artifact: &str, status: TestStatus) -> TestResult { + TestResult { + artifact: artifact.to_string(), + status, + duration: None, + message: None, + } + } + + #[test] + fn test_status_display() { + assert_eq!(TestStatus::Pass.to_string(), "pass"); + assert_eq!(TestStatus::Fail.to_string(), "fail"); + assert_eq!(TestStatus::Skip.to_string(), "skip"); + assert_eq!(TestStatus::Error.to_string(), "error"); + assert_eq!(TestStatus::Blocked.to_string(), "blocked"); + } + + #[test] + fn test_status_is_pass_fail() { + assert!(TestStatus::Pass.is_pass()); + assert!(!TestStatus::Fail.is_pass()); + assert!(!TestStatus::Skip.is_pass()); + assert!(!TestStatus::Error.is_pass()); + assert!(!TestStatus::Blocked.is_pass()); + + assert!(TestStatus::Fail.is_fail()); + assert!(TestStatus::Error.is_fail()); + assert!(!TestStatus::Pass.is_fail()); + assert!(!TestStatus::Skip.is_fail()); + assert!(!TestStatus::Blocked.is_fail()); + } + + #[test] + fn test_result_store_insert_and_sort() { + let mut store = ResultStore::new(); + assert!(store.is_empty()); + + let run_old = make_run( + "run-1", + "2026-03-01T00:00:00Z", + vec![make_result("A-1", TestStatus::Pass)], + ); + let run_new = make_run( + "run-2", + "2026-03-05T00:00:00Z", + vec![make_result("A-1", TestStatus::Fail)], + ); + + // Insert older first, then newer + store.insert(run_old); + store.insert(run_new); + + assert_eq!(store.len(), 2); + // Newest first + assert_eq!(store.runs()[0].run.id, "run-2"); + assert_eq!(store.runs()[1].run.id, "run-1"); + } + + #[test] + fn test_latest_for() { + let mut store = ResultStore::new(); + + store.insert(make_run( + "run-1", + "2026-03-01T00:00:00Z", + vec![make_result("A-1", TestStatus::Fail)], + )); + store.insert(make_run( + "run-2", + "2026-03-05T00:00:00Z", + vec![make_result("A-1", TestStatus::Pass)], + )); + + let (meta, result) = store.latest_for("A-1").unwrap(); + assert_eq!(meta.id, "run-2"); + assert_eq!(result.status, TestStatus::Pass); + + assert!(store.latest_for("NONEXISTENT").is_none()); + } + + #[test] + fn test_history_for() { + let mut store = ResultStore::new(); + + store.insert(make_run( + "run-1", + "2026-03-01T00:00:00Z", + vec![make_result("A-1", TestStatus::Fail)], + )); + store.insert(make_run( + "run-2", + "2026-03-05T00:00:00Z", + vec![make_result("A-1", TestStatus::Pass)], + )); + store.insert(make_run( + "run-3", + "2026-03-03T00:00:00Z", + vec![make_result("B-1", TestStatus::Skip)], + )); + + let history = store.history_for("A-1"); + assert_eq!(history.len(), 2); + // Newest first + assert_eq!(history[0].0.id, "run-2"); + assert_eq!(history[0].1.status, TestStatus::Pass); + assert_eq!(history[1].0.id, "run-1"); + assert_eq!(history[1].1.status, TestStatus::Fail); + + // B-1 only appears in run-3 + let history_b = store.history_for("B-1"); + assert_eq!(history_b.len(), 1); + assert_eq!(history_b[0].0.id, "run-3"); + } + + #[test] + fn test_summary() { + let mut store = ResultStore::new(); + + store.insert(make_run( + "run-1", + "2026-03-01T00:00:00Z", + vec![ + make_result("A-1", TestStatus::Pass), + make_result("A-2", TestStatus::Fail), + ], + )); + store.insert(make_run( + "run-2", + "2026-03-05T00:00:00Z", + vec![ + make_result("A-1", TestStatus::Pass), + make_result("A-2", TestStatus::Pass), + make_result("A-3", TestStatus::Skip), + make_result("A-4", TestStatus::Error), + make_result("A-5", TestStatus::Blocked), + ], + )); + + let summary = store.summary(); + assert_eq!(summary.total_runs, 2); + // Stats come from the latest run only (run-2) + assert_eq!(summary.total_results, 5); + assert_eq!(summary.pass_count, 2); + assert_eq!(summary.fail_count, 0); + assert_eq!(summary.skip_count, 1); + assert_eq!(summary.error_count, 1); + assert_eq!(summary.blocked_count, 1); + // pass_rate = 2/5 = 40% + assert!((summary.pass_rate() - 40.0).abs() < f64::EPSILON); + } + + #[test] + fn test_load_results_empty_dir() { + let dir = std::env::temp_dir().join("rivet_test_empty_results"); + let _ = std::fs::create_dir_all(&dir); + // Remove any leftover yaml files + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let _ = std::fs::remove_file(entry.path()); + } + } + + let runs = load_results(&dir).unwrap(); + assert!(runs.is_empty()); + + let _ = std::fs::remove_dir(&dir); + } + + #[test] + fn test_load_results_nonexistent_dir() { + let dir = std::env::temp_dir().join("rivet_test_nonexistent_results_dir"); + let _ = std::fs::remove_dir_all(&dir); // ensure it doesn't exist + let runs = load_results(&dir).unwrap(); + assert!(runs.is_empty()); + } + + #[test] + fn test_roundtrip_yaml() { + let run_file = TestRunFile { + run: RunMetadata { + id: "run-roundtrip".to_string(), + timestamp: "2026-03-08T12:00:00Z".to_string(), + source: Some("CI".to_string()), + environment: Some("HIL bench".to_string()), + commit: Some("abc123".to_string()), + }, + results: vec![ + TestResult { + artifact: "UVER-1".to_string(), + status: TestStatus::Pass, + duration: Some("1.5s".to_string()), + message: None, + }, + TestResult { + artifact: "UVER-2".to_string(), + status: TestStatus::Fail, + duration: None, + message: Some("Threshold exceeded".to_string()), + }, + TestResult { + artifact: "UVER-3".to_string(), + status: TestStatus::Skip, + duration: None, + message: None, + }, + TestResult { + artifact: "UVER-4".to_string(), + status: TestStatus::Error, + duration: None, + message: Some("Runtime panic".to_string()), + }, + TestResult { + artifact: "UVER-5".to_string(), + status: TestStatus::Blocked, + duration: None, + message: Some("Dependency unavailable".to_string()), + }, + ], + }; + + let yaml = serde_yaml::to_string(&run_file).unwrap(); + let deserialized: TestRunFile = serde_yaml::from_str(&yaml).unwrap(); + + assert_eq!(deserialized.run.id, run_file.run.id); + assert_eq!(deserialized.run.timestamp, run_file.run.timestamp); + assert_eq!(deserialized.run.source, run_file.run.source); + assert_eq!(deserialized.run.environment, run_file.run.environment); + assert_eq!(deserialized.run.commit, run_file.run.commit); + assert_eq!(deserialized.results.len(), run_file.results.len()); + + for (orig, deser) in run_file.results.iter().zip(deserialized.results.iter()) { + assert_eq!(orig.artifact, deser.artifact); + assert_eq!(orig.status, deser.status); + assert_eq!(orig.duration, deser.duration); + assert_eq!(orig.message, deser.message); + } + } +}