diff --git a/docs/getting-started.md b/docs/getting-started.md index bc53708..1d774dd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,7 +26,7 @@ cargo build --release # Binary at target/release/rivet ``` -Requires Rust edition 2024 (MSRV 1.85). +Requires Rust edition 2024 (MSRV 1.89). --- @@ -581,6 +581,142 @@ rivet --schemas ../../schemas validate --- +## AADL Architecture Integration (spar) + +Rivet integrates with [spar](https://github.com/pulseengine/spar), an AADL v2.2 +toolchain, to make architecture models first-class lifecycle artifacts. AADL +components become traceable from requirements through architecture to verification. + +### Setup + +1. Add the `aadl` schema to your `rivet.yaml`: + +```yaml +project: + schemas: + - common + - dev + - aadl # AADL architecture types +``` + +2. Create an `arch/` directory with your `.aadl` files and list it under `docs:`: + +```yaml +docs: + - docs + - arch # AADL models (browsable in dashboard, used for diagrams) +``` + +3. Build or fetch the spar WASM component for browser-side diagram rendering: + +```bash +# Option A: build from spar source (requires spar repo + wasm32-wasip2 target) +./scripts/build-wasm.sh /path/to/spar + +# Option B: fetch pre-built from GitHub releases +./scripts/fetch-wasm.sh +``` + +`build-wasm.sh` compiles spar to WASM and runs jco transpilation in one step. +The output lands in `rivet-cli/assets/wasm/js/`. + +If you only have the `.wasm` file, transpile manually: + +```bash +npx @bytecodealliance/jco transpile rivet-cli/assets/wasm/spar_wasm.wasm \ + --instantiation async -o rivet-cli/assets/wasm/js/ +``` + +### Architecture artifacts + +Create hand-authored architecture artifacts in your YAML sources that trace to +requirements and reference AADL components: + +```yaml +artifacts: + - id: ARCH-001 + type: system-arch-component + title: Core validation engine + status: approved + description: > + The validation module that checks artifacts against merged schemas. + links: + - type: allocated-from + target: REQ-004 + fields: + aadl-classifier: RivetSystem::RivetCore.Impl +``` + +The `aadl` schema defines `aadl-component`, `aadl-analysis-result`, and +`aadl-flow` artifact types with traceability rules linking them to requirements. + +### Architecture diagrams in documents + +Embed AADL architecture diagrams in any markdown document using fenced code +blocks with the `aadl` language tag: + +````markdown +## System Architecture + +```aadl +root: MyPackage::MySystem.Impl +``` + +The system consists of three subsystems... +```` + +When viewed in the dashboard (`rivet serve`), these blocks render as interactive +SVG diagrams. The spar WASM component runs client-side in the browser -- it +parses the `.aadl` files, instantiates the specified root, and renders the +component hierarchy as SVG with: + +- Color-coded nodes by AADL category (system, process, thread, etc.) +- Zoom controls (+/−/reset) and mouse wheel zoom +- Click-drag panning +- Clickable nodes that navigate to the corresponding artifact + +### Dashboard views + +Run `rivet serve` and the dashboard provides: + +- **Documents** -- Architecture docs with rendered AADL diagrams inline +- **Source browser** -- Browse `.aadl` files with syntax highlighting +- **Coverage** -- Traceability coverage showing which AADL components trace to + requirements and which lack allocation +- **Matrix** -- Traceability matrix from requirements to architecture components + +### How it works + +The AADL diagram rendering is fully client-side: + +1. The dashboard serves the jco-transpiled spar WASM module at `/wasm/` +2. When a page contains an `aadl` diagram block, the browser JS: + - Fetches `.aadl` file contents from `/source-raw/arch/` + - Loads the WASM module with a virtual WASI filesystem containing those files + - Calls `renderer.render(root, [])` to get SVG + - Inserts the SVG into the page +3. No spar CLI installation required on the server + +### Layer 2: Rust library integration + +For automated import of AADL components as rivet artifacts (without hand-authoring), +rivet-core includes an AADL adapter behind the `aadl` feature flag that uses +`spar-hir` as a Rust library: + +```yaml +sources: + - path: arch + format: aadl + config: + root: MyPackage::MySystem.Impl +``` + +This parses `.aadl` files, runs analyses, and creates artifacts automatically. +Enable with `cargo build --features aadl`. Note: auto-imported artifacts need +traceability links added separately to avoid orphans. + +--- + ## Next Steps - Read the [schema reference](schemas.md) for full details on all built-in schemas diff --git a/rivet-cli/assets/wasm/README.md b/rivet-cli/assets/wasm/README.md index 3ba2891..ac157c6 100644 --- a/rivet-cli/assets/wasm/README.md +++ b/rivet-cli/assets/wasm/README.md @@ -22,7 +22,16 @@ cp target/wasm32-wasip2/release/spar_wasm.wasm /path/to/sdlc/rivet-cli/assets/wa ### jco transpilation (for browser use) +The dashboard uses `--instantiation async` mode so the browser JS can provide +a virtual WASI filesystem with pre-fetched `.aadl` files: + +```bash +npx @bytecodealliance/jco transpile --instantiation async \ + rivet-cli/assets/wasm/spar_wasm.wasm -o rivet-cli/assets/wasm/js/ +``` + +Or use the build script which handles both compilation and transpilation: + ```bash -npx @bytecodealliance/jco transpile rivet-cli/assets/wasm/spar_wasm.wasm \ - -o rivet-cli/assets/wasm/js/ +./scripts/build-wasm.sh /path/to/spar ``` diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index d21ebb2..cee599f 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -7,7 +7,7 @@ use axum::Router; use axum::extract::{Path, Query, State}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; -use petgraph::graph::{EdgeIndex, Graph, NodeIndex}; +use petgraph::graph::{Graph, NodeIndex}; use petgraph::visit::EdgeRef; use tokio::sync::RwLock; @@ -312,12 +312,13 @@ pub async fn run( .route("/results/{run_id}", get(result_detail)) .route("/source", get(source_tree_view)) .route("/source/{*path}", get(source_file_view)) + .route("/source-raw/{*path}", get(source_raw)) .route("/diff", get(diff_view)) .route("/doc-linkage", get(doc_linkage_view)) .route("/traceability", get(traceability_view)) .route("/traceability/history", get(traceability_history)) .route("/api/links/{id}", get(api_artifact_links)) - .route("/api/render-aadl", get(api_render_aadl)) + .route("/wasm/{*path}", get(wasm_asset)) .route("/help", get(help_view)) .route("/help/docs", get(help_docs_list)) .route("/help/docs/{*slug}", get(help_docs_topic)) @@ -353,6 +354,8 @@ async fn redirect_non_htmx( && path != "/" && !path.starts_with("/?") && !path.starts_with("/api/") + && !path.starts_with("/wasm/") + && !path.starts_with("/source-raw/") { let goto = urlencoding::encode(&path); return axum::response::Redirect::to(&format!("/?goto={goto}")).into_response(); @@ -394,263 +397,128 @@ async fn api_artifact_links( axum::Json(linked_ids) } -#[derive(Debug, serde::Deserialize)] -struct RenderAadlParams { - root: String, - #[serde(default)] - highlight: Option, -} - -/// Serializable instance node from spar JSON output. -#[derive(Debug, serde::Deserialize)] -struct SparInstanceNode { - name: String, - category: String, - #[allow(dead_code)] - package: String, - #[allow(dead_code)] - type_name: String, - #[allow(dead_code)] - impl_name: Option, - #[serde(default)] - children: Vec, -} - -/// Top-level JSON output from `spar analyze --format json`. -#[derive(Debug, serde::Deserialize)] -struct SparAnalyzeOutput { - #[allow(dead_code)] - root: String, - #[serde(default)] - instance: Option, -} - -/// Recursively collect .aadl files from a directory. -fn collect_aadl_files_recursive(dir: &std::path::Path) -> Vec { - let mut files = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - return files; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "aadl") { - files.push(path); - } else if path.is_dir() { - files.extend(collect_aadl_files_recursive(&path)); - } - } - files -} - -/// GET /api/render-aadl — render an AADL component diagram as SVG. -/// -/// Shells out to `spar analyze --root {root} --format json {files}`, -/// parses the instance tree, builds a petgraph, and renders SVG via etch. -async fn api_render_aadl( +/// GET /source-raw/{*path} — serve a project file as raw text (for WASM client-side rendering). +async fn source_raw( State(state): State, - Query(params): Query, -) -> Result, Html> { - // 1. Find .aadl files: check configured sources first, then scan project dir. - let project_path = { - let guard = state.read().await; - guard.project_path_buf.clone() - }; - - let aadl_files = find_aadl_files(&project_path); - if aadl_files.is_empty() { - return Err(Html( - "
No .aadl files found in the project directory.
" - .into(), - )); - } + Path(raw_path): Path, +) -> impl IntoResponse { + let state = state.read().await; + let project_path = &state.project_path_buf; + let decoded = urlencoding::decode(&raw_path).unwrap_or(std::borrow::Cow::Borrowed(&raw_path)); + let rel_path = decoded.as_ref(); - // 2. Call spar CLI. - let mut cmd = std::process::Command::new("spar"); - cmd.arg("analyze") - .arg("--root") - .arg(¶ms.root) - .arg("--format") - .arg("json"); - for f in &aadl_files { - cmd.arg(f); - } - - let output = match cmd.output() { - Ok(o) => o, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(Html( - "
\ - spar not found. Install the spar CLI and ensure it is on your PATH.
\ - cargo install --path /path/to/spar/crates/spar-cli\ -
" - .into(), - )); + let full_path = project_path.join(rel_path); + let canonical = match full_path.canonicalize() { + Ok(p) => p, + Err(_) => { + return (axum::http::StatusCode::NOT_FOUND, "not found").into_response(); } - Err(e) => { - return Err(Html(format!( - "
Failed to run spar: {}
", - html_escape(&e.to_string()) - ))); + }; + let canonical_project = match project_path.canonicalize() { + Ok(p) => p, + Err(_) => { + return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error").into_response(); } }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Html(format!( - "
spar exited with error:
{}
", - html_escape(&stderr) - ))); - } - - // 3. Parse the JSON output. - let stdout = String::from_utf8_lossy(&output.stdout); - let parsed: SparAnalyzeOutput = serde_json::from_str(&stdout).map_err(|e| { - Html(format!( - "
Failed to parse spar JSON output: {}
", - html_escape(&e.to_string()) - )) - })?; - - let instance = parsed.instance.ok_or_else(|| { - Html(format!( - "
No instance model produced for root {}. \ - Check that the root classifier exists and has an implementation.
", - html_escape(¶ms.root) - )) - })?; - - // 4. Build a petgraph from the instance tree. - let mut graph: Graph<(String, String), ()> = Graph::new(); - let mut node_indices = Vec::new(); - build_instance_graph(&instance, &mut graph, None, &mut node_indices); - - // Parse highlight set from comma-separated param. - let highlight_set: std::collections::HashSet = params - .highlight - .as_deref() - .unwrap_or("") - .split(',') - .filter(|s| !s.is_empty()) - .map(|s| s.trim().to_string()) - .collect(); - - // 5. Layout and render SVG. - let mut colors = aadl_category_colors(); - // Merge in the general type_color_map for consistent look. - for (k, v) in type_color_map() { - colors.entry(k).or_insert(v); + if !canonical.starts_with(&canonical_project) { + return (axum::http::StatusCode::FORBIDDEN, "forbidden").into_response(); } - let svg_opts = SvgOptions { - type_colors: colors, - interactive: true, - base_url: Some("/artifacts".into()), - background: Some("#fafbfc".into()), - font_size: 12.0, - edge_color: "#888".into(), - highlight: if highlight_set.len() == 1 { - highlight_set.into_iter().next() - } else { - None - }, - ..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 metadata = match std::fs::symlink_metadata(&full_path) { + Ok(m) => m, + Err(_) => return (axum::http::StatusCode::NOT_FOUND, "not found").into_response(), }; - let gl = pgv_layout::layout( - &graph, - &|_idx: NodeIndex, (name, category): &(String, String)| NodeInfo { - id: name.clone(), - label: name.clone(), - node_type: category.clone(), - sublabel: Some(category.clone()), - }, - &|_idx: EdgeIndex, _e: &()| EdgeInfo { - label: String::new(), - }, - &layout_opts, - ); - - let svg = render_svg(&gl, &svg_opts); - - Ok(Html(svg)) -} - -/// Find .aadl files for the project: check rivet.yaml sources first, then scan. -fn find_aadl_files(project_path: &std::path::Path) -> Vec { - // Try loading the project config to find AADL-format sources. - let config_path = project_path.join("rivet.yaml"); - if let Ok(content) = std::fs::read_to_string(&config_path) { - if let Ok(config) = serde_yaml::from_str::(&content) { - let mut files = Vec::new(); - for source in &config.sources { - if source.format == "aadl" { - let dir = project_path.join(&source.path); - files.extend(collect_aadl_files_recursive(&dir)); + // Directory: return JSON listing of filenames. + if metadata.is_dir() { + let mut entries = Vec::new(); + if let Ok(dir) = std::fs::read_dir(&full_path) { + for entry in dir.flatten() { + if let Some(name) = entry.file_name().to_str() { + entries.push(name.to_string()); } } - if !files.is_empty() { - return files; - } } + entries.sort(); + let json = serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()); + return ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/json")], + json, + ) + .into_response(); } - // Fallback: scan the entire project directory for .aadl files. - collect_aadl_files_recursive(project_path) + match std::fs::read_to_string(&full_path) { + Ok(content) => ( + axum::http::StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + "text/plain; charset=utf-8", + )], + content, + ) + .into_response(), + Err(_) => (axum::http::StatusCode::NOT_FOUND, "not found").into_response(), + } } -/// Recursively add instance tree nodes and edges to a petgraph. -fn build_instance_graph( - node: &SparInstanceNode, - graph: &mut Graph<(String, String), ()>, - parent: Option, - indices: &mut Vec, -) { - let idx = graph.add_node((node.name.clone(), node.category.clone())); - indices.push(idx); +/// GET /wasm/{*path} — serve jco-transpiled WASM assets for browser-side rendering. +async fn wasm_asset(Path(path): Path) -> impl IntoResponse { + // Assets are in rivet-cli/assets/wasm/js/ relative to the binary, + // but we embed critical files at compile time for portability. + let content_type = if path.ends_with(".js") { + "application/javascript" + } else if path.ends_with(".wasm") { + "application/wasm" + } else if path.ends_with(".d.ts") { + "application/typescript" + } else { + "application/octet-stream" + }; - if let Some(parent_idx) = parent { - graph.add_edge(parent_idx, idx, ()); - } + // Try to load from the assets directory next to the binary, or from + // the workspace assets dir during development. + let candidates = [ + std::env::current_dir() + .unwrap_or_default() + .join("rivet-cli/assets/wasm/js") + .join(&path), + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(std::path::Path::new(".")) + .join("assets/wasm/js") + .join(&path), + ]; - for child in &node.children { - build_instance_graph(child, graph, Some(idx), indices); + for candidate in &candidates { + if let Ok(bytes) = std::fs::read(candidate) { + return ( + axum::http::StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, content_type)], + bytes, + ) + .into_response(); + } } -} -/// AADL-specific category color palette. -fn aadl_category_colors() -> HashMap { - let pairs: &[(&str, &str)] = &[ - ("system", "#4a90d9"), - ("process", "#50b848"), - ("thread", "#f5a623"), - ("thread-group", "#e8913a"), - ("processor", "#9b59b6"), - ("virtual-processor", "#af7ac5"), - ("memory", "#e74c3c"), - ("bus", "#1abc9c"), - ("virtual-bus", "#48c9b0"), - ("device", "#34495e"), - ("abstract", "#95a5a6"), - ("data", "#3498db"), - ("subprogram", "#e67e22"), - ("subprogram-group", "#d35400"), - ]; - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() + ( + axum::http::StatusCode::NOT_FOUND, + [(axum::http::header::CONTENT_TYPE, "text/plain")], + format!("WASM asset not found: {path}").into_bytes(), + ) + .into_response() } /// POST /reload — re-read the project from disk and replace the shared state. -async fn reload_handler(State(state): State) -> impl IntoResponse { +/// +/// Uses the `HX-Current-URL` header (sent automatically by HTMX) to redirect +/// back to the current page after reload, preserving the user's position. +async fn reload_handler( + State(state): State, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { let (project_path, schemas_dir, port) = { let guard = state.read().await; ( @@ -664,18 +532,36 @@ async fn reload_handler(State(state): State) -> impl IntoResponse { Ok(new_state) => { let mut guard = state.write().await; *guard = new_state; + + // Redirect back to wherever the user was (HTMX sends HX-Current-URL). + // Extract the path portion from the full URL (e.g. "http://localhost:3001/documents/DOC-001" → "/documents/DOC-001"). + let redirect_url = headers + .get("HX-Current-URL") + .and_then(|v| v.to_str().ok()) + .and_then(|full_url| { + // Find the path after the authority (scheme://host[:port]) + full_url + .find("://") + .and_then(|i| full_url[i + 3..].find('/')) + .map(|j| { + let start = full_url.find("://").unwrap() + 3 + j; + full_url[start..].to_owned() + }) + }) + .unwrap_or_else(|| "/".to_owned()); + ( axum::http::StatusCode::OK, - [("HX-Refresh", "true")], - "reloaded", + [("HX-Redirect", redirect_url)], + "reloaded".to_owned(), ) } Err(e) => { eprintln!("reload error: {e:#}"); ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, - [("HX-Refresh", "false")], - "reload failed", + [("HX-Redirect", "/".to_owned())], + format!("reload failed: {e}"), ) } } @@ -1252,10 +1138,27 @@ details.trace-details[open]>summary .trace-chevron{transform:rotate(90deg)} 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)} -.aadl-diagram{background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1rem;margin:1rem 0} -.aadl-diagram svg{width:100%;height:auto;max-height:600px} -.aadl-loading{color:var(--text-secondary);font-style:italic} -.aadl-error{color:var(--danger);font-style:italic} +.aadl-diagram{background:var(--card-bg);border:1px solid var(--border);border-radius:8px; + margin:1.5rem 0;overflow:hidden;position:relative} +.aadl-diagram .aadl-caption{display:flex;align-items:center;justify-content:space-between; + padding:.5rem 1rem;border-bottom:1px solid var(--border);background:var(--nav-bg); + font-size:.82rem;color:var(--text-secondary)} +.aadl-caption .aadl-title{font-weight:600;color:var(--text);font-family:var(--mono);font-size:.85rem} +.aadl-caption .aadl-badge{display:inline-block;padding:.1rem .5rem;border-radius:var(--radius-sm); + background:var(--primary);color:#fff;font-size:.72rem;font-weight:600;letter-spacing:.02em} +.aadl-controls{display:flex;gap:.25rem} +.aadl-controls button{background:var(--card-bg);border:1px solid var(--border);border-radius:var(--radius-sm); + width:1.7rem;height:1.7rem;cursor:pointer;font-size:.85rem;line-height:1;display:flex; + align-items:center;justify-content:center;color:var(--text-secondary);transition:all .15s} +.aadl-controls button:hover{background:var(--primary);color:#fff;border-color:var(--primary)} +.aadl-viewport{overflow:hidden;padding:.5rem;cursor:grab;min-height:200px;position:relative} +.aadl-viewport.grabbing{cursor:grabbing} +.aadl-viewport svg{width:100%;height:auto;transition:transform .15s ease;transform-origin:center center} +.aadl-viewport svg .node rect{rx:6;ry:6;filter:drop-shadow(0 1px 2px rgba(0,0,0,.08))} +.aadl-viewport svg .node text{font-family:system-ui,-apple-system,sans-serif} +.aadl-viewport svg .edge path{stroke-dasharray:none} +.aadl-loading{color:var(--text-secondary);font-style:italic;padding:2rem;text-align:center} +.aadl-error{color:var(--danger);font-style:italic;padding:1rem} "#; // ── Pan/zoom JS ────────────────────────────────────────────────────────── @@ -1753,72 +1656,298 @@ const SEARCH_JS: &str = r#" // ── AADL diagram JS ───────────────────────────────────────────────────── const AADL_JS: &str = r#" - "#; diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index ed749ac..1068ce4 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -22,7 +22,7 @@ echo "Copied WASM component to $OUT_DIR/spar_wasm.wasm" ls -lh "$OUT_DIR/spar_wasm.wasm" echo "" -echo "Transpiling for browser with jco..." -npx @bytecodealliance/jco transpile "$OUT_DIR/spar_wasm.wasm" -o "$OUT_DIR/js/" 2>&1 +echo "Transpiling for browser with jco (--instantiation async)..." +npx @bytecodealliance/jco transpile --instantiation async "$OUT_DIR/spar_wasm.wasm" -o "$OUT_DIR/js/" 2>&1 echo "Browser JS module written to $OUT_DIR/js/" ls -lh "$OUT_DIR/js/spar_wasm.js" "$OUT_DIR/js/spar_wasm.core.wasm" 2>/dev/null || true