From 90ac8150ae811dd5fdb442daceea1153f766f28b Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Mar 2026 06:59:39 +0100 Subject: [PATCH 1/4] fix(lsp): track and clear cross-file stale diagnostics Track which files had diagnostics and explicitly clear them when they no longer have issues. Fixes stale diagnostics persisting after fixing cross-file link errors. Fixes: UCA-C-2 Refs: FEAT-066 --- rivet-cli/src/main.rs | 128 +++++++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 20 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index b960997..2099e70 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -449,6 +449,20 @@ enum Command { config_entries: Vec<(String, String)>, }, + /// Import test results or artifacts from external formats + ImportResults { + /// Input format (currently: "junit") + #[arg(long)] + format: String, + + /// Input file path + file: PathBuf, + + /// Output directory for results YAML (default: results/) + #[arg(long, default_value = "results")] + output: PathBuf, + }, + /// Print the next available ID for a given artifact type or prefix NextId { /// Artifact type (e.g., requirement, feature, design-decision) @@ -830,6 +844,11 @@ fn run(cli: Cli) -> Result { source, config_entries, } => cmd_import(adapter, source, config_entries), + Command::ImportResults { + format, + file, + output, + } => cmd_import_results(format, file, output), Command::NextId { r#type, prefix, @@ -4413,6 +4432,61 @@ fn cmd_import( Ok(true) } +/// Import test results from external formats (currently: JUnit XML). +fn cmd_import_results(format: &str, file: &std::path::Path, output: &std::path::Path) -> Result { + use rivet_core::junit::{parse_junit_xml, ImportSummary}; + use rivet_core::results::TestRunFile; + + match format { + "junit" => { + let xml = std::fs::read_to_string(file) + .with_context(|| format!("failed to read {}", file.display()))?; + + let runs = parse_junit_xml(&xml) + .with_context(|| format!("failed to parse JUnit XML from {}", file.display()))?; + + if runs.is_empty() { + println!("No test suites found in {}", file.display()); + return Ok(true); + } + + std::fs::create_dir_all(output) + .with_context(|| format!("failed to create output directory {}", output.display()))?; + + for run in &runs { + let filename = format!("{}.yaml", run.run.id); + let out_path = output.join(&filename); + let run_file = TestRunFile { + run: run.run.clone(), + results: run.results.clone(), + }; + let yaml = serde_yaml::to_string(&run_file) + .context("failed to serialize run to YAML")?; + std::fs::write(&out_path, &yaml) + .with_context(|| format!("failed to write {}", out_path.display()))?; + } + + let summary = ImportSummary::from_runs(&runs); + println!( + "Imported {} test results ({} pass, {} fail, {} error, {} skip) → {}", + summary.total, + summary.pass, + summary.fail, + summary.error, + summary.skip, + output.display(), + ); + + Ok(true) + } + other => { + anyhow::bail!( + "unknown import format: '{other}' (supported: junit)" + ) + } + } +} + /// Parse a key=value pair for mutation commands. fn parse_key_val_mutation(s: &str) -> Result<(String, String), String> { let pos = s @@ -5030,7 +5104,9 @@ fn cmd_lsp(cli: &Cli) -> Result { // Publish initial diagnostics from salsa let store = db.store(source_set); let diagnostics = db.diagnostics(source_set, schema_set); - lsp_publish_salsa_diagnostics(&connection, &diagnostics, &store); + let mut prev_diagnostic_files: std::collections::HashSet = + std::collections::HashSet::new(); + lsp_publish_salsa_diagnostics(&connection, &diagnostics, &store, &mut prev_diagnostic_files); eprintln!( "rivet lsp: initialized with {} artifacts (salsa incremental)", store.len() @@ -5401,6 +5477,7 @@ fn cmd_lsp(cli: &Cli) -> Result { &connection, &new_diagnostics, &new_store, + &mut prev_diagnostic_files, ); eprintln!( "rivet lsp: incremental revalidation complete ({} diagnostics, {} artifacts)", @@ -5455,6 +5532,7 @@ fn cmd_lsp(cli: &Cli) -> Result { &connection, &diagnostics, &store, + &mut prev_diagnostic_files, ); } } @@ -5532,10 +5610,17 @@ fn lsp_word_at_position(content: &str, line: u32, character: u32) -> String { /// /// Takes pre-computed diagnostics and the current store (both from salsa), /// maps them to LSP diagnostic notifications grouped by source file. +/// +/// `prev_diagnostic_files` tracks which files had diagnostics on the previous +/// call. Files that previously had diagnostics but no longer do receive an +/// explicit empty publish, clearing stale markers in the editor. This handles +/// the cross-file case: fixing a broken link in file A clears diagnostics in +/// file B that referenced A, even if B has no artifacts being reloaded. fn lsp_publish_salsa_diagnostics( connection: &lsp_server::Connection, diagnostics: &[validate::Diagnostic], store: &Store, + prev_diagnostic_files: &mut std::collections::HashSet, ) { use lsp_types::*; @@ -5574,7 +5659,7 @@ fn lsp_publish_salsa_diagnostics( } } - // Publish diagnostics for files that have them + // Publish diagnostics for files that currently have them for (path, diags) in &file_diags { if let Some(uri) = lsp_path_to_uri(path) { let params = PublishDiagnosticsParams { @@ -5591,28 +5676,31 @@ fn lsp_publish_salsa_diagnostics( } } - // Clear diagnostics for source files that no longer have issues. - // Without this, stale diagnostics remain in VS Code after fixes. - for artifact in store.iter() { - if let Some(ref path) = artifact.source_file { - if !file_diags.contains_key(path) { - if let Some(uri) = lsp_path_to_uri(path) { - let params = PublishDiagnosticsParams { - uri, - diagnostics: Vec::new(), - version: None, - }; - let _ = connection.sender.send(lsp_server::Message::Notification( - lsp_server::Notification { - method: "textDocument/publishDiagnostics".to_string(), - params: serde_json::to_value(params).unwrap(), - }, - )); - } + // Clear diagnostics for files that had them last time but no longer do. + // This covers cross-file cases (e.g. fixing a broken link in ucas.yaml + // clears stale errors in controller-constraints.yaml) and also the edge + // case where a file's artifacts were removed from the store entirely. + for path in prev_diagnostic_files.iter() { + if !file_diags.contains_key(path) { + if let Some(uri) = lsp_path_to_uri(path) { + let params = PublishDiagnosticsParams { + uri, + diagnostics: Vec::new(), + version: None, + }; + let _ = connection.sender.send(lsp_server::Message::Notification( + lsp_server::Notification { + method: "textDocument/publishDiagnostics".to_string(), + params: serde_json::to_value(params).unwrap(), + }, + )); } } } + // Update the tracked set to reflect this publish cycle + *prev_diagnostic_files = file_diags.keys().cloned().collect(); + eprintln!( "rivet lsp: published {} diagnostics across {} files", diagnostics.len(), From 8afb389b12282e86a87831314fc6201a0c4d513c Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Mar 2026 09:13:24 +0100 Subject: [PATCH 2/4] feat(cli): add rivet import-results --format junit for test result import Parse JUnit XML test results and write as rivet TestRun YAML. Maps testcase names to artifact IDs where possible (classname exact match, bracketed [ID] in name/classname, or classname.name fallback). Includes 16 unit tests covering all artifact ID heuristics and XML parsing (pass, fail, error, skip, multiple suites, bare testsuite). Implements: FEAT-071 Refs: REQ-040 --- rivet-core/src/junit.rs | 581 ++++++++++++++++++++++++++++++++++++++++ rivet-core/src/lib.rs | 1 + 2 files changed, 582 insertions(+) create mode 100644 rivet-core/src/junit.rs diff --git a/rivet-core/src/junit.rs b/rivet-core/src/junit.rs new file mode 100644 index 0000000..7daa243 --- /dev/null +++ b/rivet-core/src/junit.rs @@ -0,0 +1,581 @@ +//! JUnit XML test result importer. +//! +//! Parses JUnit XML test result files (the de-facto standard produced by Maven, +//! pytest, cargo-nextest, etc.) and maps them to Rivet [`TestRun`] / [`TestResult`] +//! structures. +//! +//! ## Artifact ID extraction +//! +//! The importer tries to find a rivet artifact ID in a testcase using these +//! heuristics (first match wins): +//! +//! 1. The `classname` attribute matches an ID pattern directly +//! (e.g., `classname="REQ-001"`). +//! 2. The `name` or `classname` attribute contains a bracketed ID pattern +//! (e.g., `name="test_foo [REQ-001]"` → `REQ-001`). +//! 3. Fall back to `classname.name` as a plain string reference. +//! +//! An "artifact ID pattern" is: one or more uppercase letters, a hyphen, then +//! one or more digits (e.g., `REQ-001`, `FEAT-071`, `TEST-013`). + +use std::collections::HashMap; + +use quick_xml::events::Event; +use quick_xml::Reader; + +use crate::error::Error; +use crate::results::{RunMetadata, TestResult, TestRun, TestStatus}; + +// ── Artifact ID detection ──────────────────────────────────────────────────── + +/// Returns true if `s` is exactly an artifact ID like `REQ-001` or `FEAT-071`. +fn is_artifact_id(s: &str) -> bool { + let bytes = s.as_bytes(); + let mut i = 0; + // One or more uppercase ASCII letters + if i >= bytes.len() || !bytes[i].is_ascii_uppercase() { + return false; + } + while i < bytes.len() && bytes[i].is_ascii_uppercase() { + i += 1; + } + // Hyphen separator + if i >= bytes.len() || bytes[i] != b'-' { + return false; + } + i += 1; + // One or more ASCII digits + if i >= bytes.len() || !bytes[i].is_ascii_digit() { + return false; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + // Nothing else allowed + i == bytes.len() +} + +/// Extract the first `[ARTIFACT-ID]` pattern from a string. +fn extract_bracketed_id(s: &str) -> Option<&str> { + let mut start = 0; + while let Some(open) = s[start..].find('[') { + let abs_open = start + open + 1; + if let Some(close) = s[abs_open..].find(']') { + let candidate = &s[abs_open..abs_open + close]; + if is_artifact_id(candidate) { + return Some(candidate); + } + start = abs_open + close + 1; + } else { + break; + } + } + None +} + +/// Derive the best artifact reference from a testcase's `name` and `classname`. +pub fn artifact_id_for(name: &str, classname: &str) -> String { + // 1. classname is itself an artifact ID + if is_artifact_id(classname) { + return classname.to_string(); + } + // 2. bracketed ID in name + if let Some(id) = extract_bracketed_id(name) { + return id.to_string(); + } + // 3. bracketed ID in classname + if let Some(id) = extract_bracketed_id(classname) { + return id.to_string(); + } + // 4. fall back to "classname.name" (or just whichever is non-empty) + match (classname.is_empty(), name.is_empty()) { + (true, _) => name.to_string(), + (_, true) => classname.to_string(), + _ => format!("{}.{}", classname, name), + } +} + +// ── Internal parse model ───────────────────────────────────────────────────── + +#[derive(Debug, Default)] +struct ParsedCase { + name: String, + classname: String, + time: Option, + outcome: Outcome, + /// Text body of or , used as fallback message. + body: Option, +} + +#[derive(Debug, Default, PartialEq)] +enum Outcome { + #[default] + Pass, + Fail { message: Option }, + Error { message: Option }, + Skipped, +} + +#[derive(Debug, Default)] +struct ParsedSuite { + name: String, + timestamp: Option, + cases: Vec, +} + +// ── Parser ─────────────────────────────────────────────────────────────────── + +/// Tracks what text-content context we are in. +#[derive(Debug, PartialEq)] +enum TextContext { + Failure, + Error, + None, +} + +/// Parse JUnit XML and return one [`TestRun`] per `` element. +/// +/// Both `` wrappers and bare `` roots are accepted. +pub fn parse_junit_xml(xml: &str) -> Result, Error> { + let suites = parse_suites(xml)?; + Ok(suites + .into_iter() + .enumerate() + .map(|(i, s)| suite_to_run(s, i)) + .collect()) +} + +fn parse_suites(xml: &str) -> Result, Error> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut suites: Vec = Vec::new(); + let mut current_suite: Option = None; + let mut current_case: Option = None; + let mut text_ctx = TextContext::None; + let mut buf = Vec::new(); + + // Helper closures as inline logic below. + loop { + match reader.read_event_into(&mut buf) { + Err(e) => return Err(Error::Adapter(format!("JUnit XML parse error: {e}"))), + Ok(Event::Eof) => break, + + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + let tag = tag_name(e); + let attrs = collect_attrs(e); + + match tag.as_str() { + "testsuite" => { + // Commit any previous suite + if let Some(s) = current_suite.take() { + suites.push(s); + } + current_suite = Some(ParsedSuite { + name: attrs.get("name").cloned().unwrap_or_default(), + timestamp: attrs.get("timestamp").cloned(), + cases: Vec::new(), + }); + current_case = None; + text_ctx = TextContext::None; + } + "testcase" => { + // Commit any previous case + if let Some(c) = current_case.take() { + push_case(&mut current_suite, c); + } + current_case = Some(ParsedCase { + name: attrs.get("name").cloned().unwrap_or_default(), + classname: attrs.get("classname").cloned().unwrap_or_default(), + time: attrs.get("time").cloned(), + outcome: Outcome::Pass, + body: None, + }); + text_ctx = TextContext::None; + } + "failure" => { + if let Some(ref mut c) = current_case { + c.outcome = Outcome::Fail { + message: attrs.get("message").cloned(), + }; + } + text_ctx = TextContext::Failure; + } + "error" => { + if let Some(ref mut c) = current_case { + c.outcome = Outcome::Error { + message: attrs.get("message").cloned(), + }; + } + text_ctx = TextContext::Error; + } + "skipped" | "skip" => { + if let Some(ref mut c) = current_case { + c.outcome = Outcome::Skipped; + } + } + _ => {} + } + } + + Ok(Event::End(ref e)) => { + let tag = std::str::from_utf8(e.local_name().as_ref()) + .unwrap_or("") + .to_lowercase(); + match tag.as_str() { + "failure" | "error" => { + text_ctx = TextContext::None; + } + "testcase" => { + if let Some(c) = current_case.take() { + push_case(&mut current_suite, c); + } + text_ctx = TextContext::None; + } + "testsuite" => { + // Flush pending case first + if let Some(c) = current_case.take() { + push_case(&mut current_suite, c); + } + if let Some(s) = current_suite.take() { + suites.push(s); + } + text_ctx = TextContext::None; + } + _ => {} + } + } + + Ok(Event::Text(ref e)) => { + if text_ctx == TextContext::Failure || text_ctx == TextContext::Error { + if let Some(ref mut c) = current_case { + if c.body.is_none() { + let text = e.unescape().map(|s| s.trim().to_string()).unwrap_or_default(); + if !text.is_empty() { + c.body = Some(text); + } + } + } + } + } + + _ => {} + } + buf.clear(); + } + + // Flush any trailing state (document without proper close tags) + if let Some(c) = current_case.take() { + push_case(&mut current_suite, c); + } + if let Some(s) = current_suite.take() { + suites.push(s); + } + + Ok(suites) +} + +fn push_case(suite: &mut Option, case: ParsedCase) { + if let Some(s) = suite { + s.cases.push(case); + } +} + +fn tag_name(e: &quick_xml::events::BytesStart<'_>) -> String { + std::str::from_utf8(e.local_name().as_ref()) + .unwrap_or("") + .to_lowercase() +} + +fn collect_attrs(e: &quick_xml::events::BytesStart<'_>) -> HashMap { + let mut map = HashMap::new(); + for attr in e.attributes().flatten() { + if let (Ok(key), Ok(val)) = ( + std::str::from_utf8(attr.key.local_name().as_ref()), + attr.unescape_value(), + ) { + map.insert(key.to_string(), val.into_owned()); + } + } + map +} + +// ── Conversion ─────────────────────────────────────────────────────────────── + +fn suite_to_run(suite: ParsedSuite, index: usize) -> TestRun { + let safe_name: String = suite + .name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) + .collect(); + let run_id = if safe_name.is_empty() { + format!("junit-import-{index}") + } else { + format!("junit-{safe_name}") + }; + + let timestamp = suite + .timestamp + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + + let results = suite.cases.into_iter().map(case_to_result).collect(); + + TestRun { + run: RunMetadata { + id: run_id, + timestamp, + source: Some("junit-xml".to_string()), + environment: None, + commit: None, + }, + results, + source_file: None, + } +} + +fn case_to_result(c: ParsedCase) -> TestResult { + let artifact = artifact_id_for(&c.name, &c.classname); + let duration = c.time.map(|t| format!("{t}s")); + + // Prefer the `message` attribute; fall back to element text body. + let message = match &c.outcome { + Outcome::Fail { message: Some(m) } => Some(m.clone()), + Outcome::Error { message: Some(m) } => Some(m.clone()), + Outcome::Fail { message: None } | Outcome::Error { message: None } => c.body, + _ => None, + }; + + let status = match c.outcome { + Outcome::Pass => TestStatus::Pass, + Outcome::Fail { .. } => TestStatus::Fail, + Outcome::Error { .. } => TestStatus::Error, + Outcome::Skipped => TestStatus::Skip, + }; + + TestResult { + artifact, + status, + duration, + message, + } +} + +// ── Import summary ──────────────────────────────────────────────────────────── + +/// Aggregate statistics over the runs produced by an import. +pub struct ImportSummary { + pub total: usize, + pub pass: usize, + pub fail: usize, + pub error: usize, + pub skip: usize, +} + +impl ImportSummary { + pub fn from_runs(runs: &[TestRun]) -> Self { + let mut s = Self { total: 0, pass: 0, fail: 0, error: 0, skip: 0 }; + for run in runs { + for r in &run.results { + s.total += 1; + match r.status { + TestStatus::Pass => s.pass += 1, + TestStatus::Fail => s.fail += 1, + TestStatus::Error => s.error += 1, + TestStatus::Skip => s.skip += 1, + TestStatus::Blocked => {} + } + } + } + s + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // --- artifact ID helpers --- + + #[test] + fn test_is_artifact_id() { + assert!(is_artifact_id("REQ-001")); + assert!(is_artifact_id("FEAT-071")); + assert!(is_artifact_id("TEST-013")); + assert!(is_artifact_id("AB-1")); + assert!(!is_artifact_id("req-001")); // lowercase + assert!(!is_artifact_id("REQ001")); // missing hyphen + assert!(!is_artifact_id("REQ-")); // no digits + assert!(!is_artifact_id("-001")); // no prefix + assert!(!is_artifact_id("")); // empty + assert!(!is_artifact_id("REQ-001 extra")); // trailing content + assert!(!is_artifact_id("com.example.MyTest")); // classname + } + + #[test] + fn test_extract_bracketed_id() { + assert_eq!(extract_bracketed_id("test_foo [REQ-001]"), Some("REQ-001")); + assert_eq!( + extract_bracketed_id("some test [FEAT-071] for feature"), + Some("FEAT-071") + ); + assert_eq!(extract_bracketed_id("no brackets here"), None); + assert_eq!(extract_bracketed_id("[not-an-id]"), None); + assert_eq!(extract_bracketed_id("[REQ-001]"), Some("REQ-001")); + } + + #[test] + fn test_artifact_id_for_classname_is_id() { + assert_eq!(artifact_id_for("some test", "REQ-001"), "REQ-001"); + } + + #[test] + fn test_artifact_id_for_bracketed_in_name() { + assert_eq!( + artifact_id_for("validate braking [FEAT-071]", "com.example"), + "FEAT-071" + ); + } + + #[test] + fn test_artifact_id_for_bracketed_in_classname() { + assert_eq!( + artifact_id_for("test_something", "suite [TEST-013]"), + "TEST-013" + ); + } + + #[test] + fn test_artifact_id_for_fallback() { + assert_eq!( + artifact_id_for("test_foo", "com.example.MyTest"), + "com.example.MyTest.test_foo" + ); + assert_eq!(artifact_id_for("test_foo", ""), "test_foo"); + assert_eq!(artifact_id_for("", "com.example"), "com.example"); + } + + // --- XML parser --- + + const SAMPLE_JUNIT: &str = r#" + + + + + Details here + + + + + + + + +"#; + + #[test] + fn test_parse_junit_xml_basic() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + assert_eq!(runs.len(), 1); + let run = &runs[0]; + assert_eq!(run.run.id, "junit-rivet-core-tests"); + assert_eq!(run.run.timestamp, "2026-03-28T10:00:00Z"); + assert_eq!(run.run.source, Some("junit-xml".to_string())); + assert_eq!(run.results.len(), 4); + } + + #[test] + fn test_parse_junit_pass_result() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + let r = &runs[0].results[0]; + assert_eq!(r.artifact, "REQ-001"); + assert_eq!(r.status, TestStatus::Pass); + assert_eq!(r.duration, Some("0.1s".to_string())); + assert!(r.message.is_none()); + } + + #[test] + fn test_parse_junit_fail_result() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + let r = &runs[0].results[1]; + assert_eq!(r.artifact, "FEAT-071"); // classname is artifact ID + assert_eq!(r.status, TestStatus::Fail); + assert_eq!( + r.message, + Some("assertion failed: expected Ok(())".to_string()) + ); + } + + #[test] + fn test_parse_junit_error_result() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + let r = &runs[0].results[2]; + assert_eq!(r.artifact, "com.example.IntegrationTest.test_timeout"); + assert_eq!(r.status, TestStatus::Error); + assert_eq!(r.message, Some("Timed out after 5s".to_string())); + } + + #[test] + fn test_parse_junit_skipped_result() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + let r = &runs[0].results[3]; + assert_eq!(r.artifact, "com.example.SkipTest.test_skipped"); + assert_eq!(r.status, TestStatus::Skip); + } + + #[test] + fn test_parse_junit_multiple_suites() { + let xml = r#" + + + + + + + + +"#; + let runs = parse_junit_xml(xml).expect("parse failed"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].run.id, "junit-Suite-A"); + assert_eq!(runs[1].run.id, "junit-Suite-B"); + assert_eq!(runs[0].results[0].status, TestStatus::Pass); + assert_eq!(runs[1].results[0].status, TestStatus::Fail); + } + + #[test] + fn test_parse_junit_bare_testsuite() { + let xml = r#" + +"#; + let runs = parse_junit_xml(xml).expect("parse failed"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].results[0].artifact, "REQ-010"); + } + + #[test] + fn test_import_summary() { + let runs = parse_junit_xml(SAMPLE_JUNIT).expect("parse failed"); + let s = ImportSummary::from_runs(&runs); + assert_eq!(s.total, 4); + assert_eq!(s.pass, 1); + assert_eq!(s.fail, 1); + assert_eq!(s.error, 1); + assert_eq!(s.skip, 1); + } + + #[test] + fn test_parse_junit_no_suites() { + let xml = ""; + let runs = parse_junit_xml(xml).expect("parse failed"); + assert!(runs.is_empty()); + } + + #[test] + fn test_parse_junit_does_not_panic_on_malformed() { + // Should not panic even on garbage input + let _ = parse_junit_xml("not xml at all <<<"); + } +} diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index b95a4b5..be3d8d0 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod mutate; #[cfg(feature = "oslc")] pub mod oslc; pub mod query; +pub mod junit; pub mod reqif; pub mod results; pub mod schema; From 652a6a8eeaf1b8361cda626af69061a87dbda78e Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Mar 2026 09:40:23 +0100 Subject: [PATCH 3/4] fix(docs): warn about markdown files without frontmatter + AGENTS.md guidance Documents without YAML frontmatter are now logged at info level instead of silently skipped. AGENTS.md updated with document requirements and guidance to use rivet commands instead of recreating statistics/coverage manually. Refs: REQ-001 --- AGENTS.md | 10 +++++++++- rivet-core/src/document.rs | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06d5e4e..9ab1632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,15 @@ This project uses **Rivet** for SDLC artifact traceability. ### File Structure - Artifacts are stored as YAML files in: `artifacts`, `safety/stpa`, `safety/stpa-sec` - Schema definitions: `schemas/` directory -- Documents: `docs`, `arch` +- Documents: `docs`, `arch` (markdown files with YAML frontmatter) + - **Documents MUST start with `---` YAML frontmatter** to be tracked by rivet + - Required frontmatter fields: `id`, `title`, `type` (usually `document`) + - Files without frontmatter (plain markdown) are silently skipped + - Plans in `docs/plans/` are NOT rivet documents (no frontmatter needed) +- Do NOT recreate statistics, coverage, linkage, or other data that rivet already provides + - Use `rivet stats`, `rivet list`, `rivet coverage`, `rivet validate` to query data + - Use `rivet export --html` to generate a full static site + - Use the VS Code extension tree view and rendered views for browsing ### Creating Artifacts ```bash diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index 04e4fe5..96861d8 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -186,8 +186,13 @@ pub fn load_documents(dir: &Path) -> Result, Error> { 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). + // Skip files without YAML frontmatter (e.g. plain README.md). + // Warn so users know these aren't being tracked. if !content.starts_with("---") { + log::info!( + "skipping {} (no YAML frontmatter — add --- header to include as rivet document)", + path.display() + ); continue; } From 92c3d894e48546da5da245e1610c71a155bc6465 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Mar 2026 11:24:33 +0100 Subject: [PATCH 4/4] style: cargo fmt Refs: FEAT-071 --- rivet-cli/src/main.rs | 28 ++++++++++++++++++---------- rivet-core/src/junit.rs | 31 +++++++++++++++++++++++++------ rivet-core/src/lib.rs | 2 +- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 2099e70..ebd34f5 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -4433,8 +4433,12 @@ fn cmd_import( } /// Import test results from external formats (currently: JUnit XML). -fn cmd_import_results(format: &str, file: &std::path::Path, output: &std::path::Path) -> Result { - use rivet_core::junit::{parse_junit_xml, ImportSummary}; +fn cmd_import_results( + format: &str, + file: &std::path::Path, + output: &std::path::Path, +) -> Result { + use rivet_core::junit::{ImportSummary, parse_junit_xml}; use rivet_core::results::TestRunFile; match format { @@ -4450,8 +4454,9 @@ fn cmd_import_results(format: &str, file: &std::path::Path, output: &std::path:: return Ok(true); } - std::fs::create_dir_all(output) - .with_context(|| format!("failed to create output directory {}", output.display()))?; + std::fs::create_dir_all(output).with_context(|| { + format!("failed to create output directory {}", output.display()) + })?; for run in &runs { let filename = format!("{}.yaml", run.run.id); @@ -4460,8 +4465,8 @@ fn cmd_import_results(format: &str, file: &std::path::Path, output: &std::path:: run: run.run.clone(), results: run.results.clone(), }; - let yaml = serde_yaml::to_string(&run_file) - .context("failed to serialize run to YAML")?; + let yaml = + serde_yaml::to_string(&run_file).context("failed to serialize run to YAML")?; std::fs::write(&out_path, &yaml) .with_context(|| format!("failed to write {}", out_path.display()))?; } @@ -4480,9 +4485,7 @@ fn cmd_import_results(format: &str, file: &std::path::Path, output: &std::path:: Ok(true) } other => { - anyhow::bail!( - "unknown import format: '{other}' (supported: junit)" - ) + anyhow::bail!("unknown import format: '{other}' (supported: junit)") } } } @@ -5106,7 +5109,12 @@ fn cmd_lsp(cli: &Cli) -> Result { let diagnostics = db.diagnostics(source_set, schema_set); let mut prev_diagnostic_files: std::collections::HashSet = std::collections::HashSet::new(); - lsp_publish_salsa_diagnostics(&connection, &diagnostics, &store, &mut prev_diagnostic_files); + lsp_publish_salsa_diagnostics( + &connection, + &diagnostics, + &store, + &mut prev_diagnostic_files, + ); eprintln!( "rivet lsp: initialized with {} artifacts (salsa incremental)", store.len() diff --git a/rivet-core/src/junit.rs b/rivet-core/src/junit.rs index 7daa243..a6ee140 100644 --- a/rivet-core/src/junit.rs +++ b/rivet-core/src/junit.rs @@ -20,8 +20,8 @@ use std::collections::HashMap; -use quick_xml::events::Event; use quick_xml::Reader; +use quick_xml::events::Event; use crate::error::Error; use crate::results::{RunMetadata, TestResult, TestRun, TestStatus}; @@ -111,8 +111,12 @@ struct ParsedCase { enum Outcome { #[default] Pass, - Fail { message: Option }, - Error { message: Option }, + Fail { + message: Option, + }, + Error { + message: Option, + }, Skipped, } @@ -250,7 +254,10 @@ fn parse_suites(xml: &str) -> Result, Error> { if text_ctx == TextContext::Failure || text_ctx == TextContext::Error { if let Some(ref mut c) = current_case { if c.body.is_none() { - let text = e.unescape().map(|s| s.trim().to_string()).unwrap_or_default(); + let text = e + .unescape() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); if !text.is_empty() { c.body = Some(text); } @@ -306,7 +313,13 @@ fn suite_to_run(suite: ParsedSuite, index: usize) -> TestRun { let safe_name: String = suite .name .chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }) + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) .collect(); let run_id = if safe_name.is_empty() { format!("junit-import-{index}") @@ -373,7 +386,13 @@ pub struct ImportSummary { impl ImportSummary { pub fn from_runs(runs: &[TestRun]) -> Self { - let mut s = Self { total: 0, pass: 0, fail: 0, error: 0, skip: 0 }; + let mut s = Self { + total: 0, + pass: 0, + fail: 0, + error: 0, + skip: 0, + }; for run in runs { for r in &run.results { s.total += 1; diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index be3d8d0..58de74c 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod export; pub mod externals; pub mod formats; pub mod impact; +pub mod junit; pub mod lifecycle; pub mod links; pub mod markdown; @@ -20,7 +21,6 @@ pub mod mutate; #[cfg(feature = "oslc")] pub mod oslc; pub mod query; -pub mod junit; pub mod reqif; pub mod results; pub mod schema;