From 0ec7c0c64cdfb4640601043eaf7409ed80bdc0a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 16:36:57 +0000 Subject: [PATCH] feat(check): add `rivet check docs` oracle with --format json + --strict (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumerates every candidate path the doc scanner considered, tagging each `loaded` / `skipped ()` / `excluded ()`. Mirrors the existing oracle pattern (`rivet check sources` / `bidirectional` / `gaps_json`) — narrow, mechanical, scriptable. Default output is human text; `--format json` emits the canonical `{oracle,entries,total, by_status}` envelope for pipeline consumers. `--strict` exits non-zero when any entry is `skipped` (explicit `excluded` allowlist matches do not trip strict). Reuses the existing `load_documents_with_report` iteration verbatim via a new `scan_documents(dir, exclude) -> Vec` that returns per-path detail instead of emitting stderr warnings — the warning-printing path is left untouched, so `rivet validate`'s output is byte-identical. Tests: `rivet-cli/tests/docs_check.rs` covers the four fixture cases (loaded / no-frontmatter / missing-id frontmatter / excluded-by-glob), the strict exit-code branches in both directions, and the human-text shape. Closes #540. Implements: REQ-007 Refs: REQ-004 --- rivet-cli/src/check/docs.rs | 166 +++++++++++++++++++ rivet-cli/src/check/mod.rs | 1 + rivet-cli/src/main.rs | 57 +++++++ rivet-cli/tests/docs_check.rs | 289 ++++++++++++++++++++++++++++++++++ rivet-core/src/document.rs | 115 ++++++++++++++ 5 files changed, 628 insertions(+) create mode 100644 rivet-cli/src/check/docs.rs create mode 100644 rivet-cli/tests/docs_check.rs diff --git a/rivet-cli/src/check/docs.rs b/rivet-cli/src/check/docs.rs new file mode 100644 index 00000000..4ce8ce25 --- /dev/null +++ b/rivet-cli/src/check/docs.rs @@ -0,0 +1,166 @@ +//! `rivet check docs` — enumerate every candidate path the doc scanner +//! considered and tag each `loaded` / `skipped ()` / +//! `excluded ()`. +//! +//! Dedicated read-only oracle so the doc-scan status is queryable +//! without a full `rivet validate` pass. Mirrors the conventions of +//! `rivet check sources`: human-text default, `--format json` for +//! mechanical assertions, `--strict` exits non-zero when any candidate +//! is skipped. Explicit `excluded` allowlist matches do **not** trip +//! `--strict` — those are user opt-in, not an oversight. +//! +//! JSON contract on `--format json`: +//! ```json +//! { +//! "oracle": "docs", +//! "entries": [ +//! { "path": "docs/foo.md", "status": "loaded" }, +//! { "path": "docs/bar.md", "status": "skipped", "reason": "no YAML frontmatter" }, +//! { "path": "docs/gen.md", "status": "excluded", "reason": "generated-*.md" } +//! ], +//! "total": 3, +//! "by_status": { "loaded": 1, "skipped": 1, "excluded": 1 } +//! } +//! ``` + +use std::path::Path; + +use anyhow::{Context, Result}; +use rivet_core::document::{self, DocScanStatus}; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EntryStatus { + Loaded, + Skipped, + Excluded, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Entry { + /// Path relative to the project root, with forward slashes — stable + /// across platforms so JSON consumers can pattern-match without + /// caring whether the harness ran on Windows. + pub path: String, + pub status: EntryStatus, + /// For `skipped`: the human-readable reason (`"no YAML frontmatter"` + /// or the frontmatter parse-error message). For `excluded`: the + /// matching glob from `docs[].exclude`. Omitted for `loaded`. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct StatusCounts { + pub loaded: usize, + pub skipped: usize, + pub excluded: usize, +} + +#[derive(Debug, Serialize)] +pub struct Report { + pub oracle: &'static str, + pub entries: Vec, + pub total: usize, + pub by_status: StatusCounts, +} + +/// Build the report from the project's configured `docs:` entries. +/// +/// Iterates each `DocsEntry`, runs `scan_documents` per directory, and +/// flattens the per-path results into a single ordered list. Paths are +/// normalized relative to `project_root` with forward slashes. +pub fn compute(project_root: &Path, docs: &[rivet_core::model::DocsEntry]) -> Result { + let mut entries = Vec::new(); + let mut by_status = StatusCounts::default(); + + for entry in docs { + let dir = project_root.join(entry.path()); + let scanned = document::scan_documents(&dir, entry.exclude()) + .with_context(|| format!("scanning docs from '{}'", entry.path()))?; + + for sd in scanned { + let (status, reason) = match sd.status { + DocScanStatus::Loaded => (EntryStatus::Loaded, None), + DocScanStatus::Skipped(r) => (EntryStatus::Skipped, Some(r)), + DocScanStatus::Excluded(p) => (EntryStatus::Excluded, Some(p)), + }; + match status { + EntryStatus::Loaded => by_status.loaded += 1, + EntryStatus::Skipped => by_status.skipped += 1, + EntryStatus::Excluded => by_status.excluded += 1, + } + entries.push(Entry { + path: relative_display(project_root, &sd.path), + status, + reason, + }); + } + } + + let total = entries.len(); + Ok(Report { + oracle: "docs", + entries, + total, + by_status, + }) +} + +/// Render `path` relative to `project_root` using forward slashes, so +/// the JSON shape is stable across platforms. Falls back to the +/// absolute path on a strip failure (canonicalization mismatch). +fn relative_display(project_root: &Path, path: &Path) -> String { + let rel = path.strip_prefix(project_root).unwrap_or(path); + rel.to_string_lossy().replace('\\', "/") +} + +/// Render the report as human-readable text: one line per entry plus a +/// trailing summary count. +pub fn render_text(report: &Report) -> String { + use std::fmt::Write; + let mut out = String::new(); + if report.entries.is_empty() { + out.push_str("No doc candidates found (check the `docs:` block in rivet.yaml).\n"); + return out; + } + for e in &report.entries { + match (&e.status, &e.reason) { + (EntryStatus::Loaded, _) => { + let _ = writeln!(out, "loaded: {}", e.path); + } + (EntryStatus::Skipped, Some(r)) => { + let _ = writeln!(out, "skipped: {}: {}", e.path, r); + } + (EntryStatus::Excluded, Some(p)) => { + let _ = writeln!(out, "excluded: {} (matched: {})", e.path, p); + } + // The reasonless skipped/excluded branches are unreachable + // by construction in `compute`, but render them gracefully + // rather than panic if anyone wires up the type by hand. + (EntryStatus::Skipped, None) => { + let _ = writeln!(out, "skipped: {}", e.path); + } + (EntryStatus::Excluded, None) => { + let _ = writeln!(out, "excluded: {}", e.path); + } + } + } + let _ = writeln!(out); + let _ = writeln!( + out, + "Total: {} (loaded: {}, skipped: {}, excluded: {})", + report.total, report.by_status.loaded, report.by_status.skipped, report.by_status.excluded, + ); + out +} + +/// Returns `true` on pass / `false` on fail (per the oracle convention). +/// +/// Pass: every candidate is either `loaded` or explicitly `excluded`. +/// Fail (only under `--strict`): any candidate is `skipped` — i.e. a +/// file the scanner declined and the user did **not** allowlist. +pub fn strict_passes(report: &Report) -> bool { + report.by_status.skipped == 0 +} diff --git a/rivet-cli/src/check/mod.rs b/rivet-cli/src/check/mod.rs index 12a5d167..3afae872 100644 --- a/rivet-cli/src/check/mod.rs +++ b/rivet-cli/src/check/mod.rs @@ -21,6 +21,7 @@ //! The JSON shape is the contract pipelines consume. pub mod bidirectional; +pub mod docs; pub mod gaps_json; pub mod review_signoff; pub mod sources; diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 4eb92a1c..1c48b8c1 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -1950,6 +1950,26 @@ enum CheckAction { #[arg(short, long, default_value = "text")] format: String, }, + + /// Enumerate every candidate path the doc scanner considered and + /// tag each `loaded` / `skipped ()` / `excluded ()`. + /// Dedicated read-only oracle so the doc-scan status is queryable + /// without running a full `rivet validate` pass. `--format json` + /// emits the canonical `{oracle, entries, total, by_status}` + /// envelope; `--strict` exits non-zero when any entry is `skipped` + /// (explicit `excluded` allowlist matches do not trip strict). + Docs { + /// Read-only audit gate: exit non-zero if any candidate is + /// `skipped` (the scanner declined the file and it is not on + /// the `docs[].exclude` allowlist). Files that match an + /// `exclude:` glob are explicit opt-in and do not trip strict. + #[arg(long)] + strict: bool, + + /// Output format: "text" (default) or "json". + #[arg(short, long, default_value = "text")] + format: String, + }, } fn main() -> ExitCode { @@ -2499,6 +2519,7 @@ fn run(cli: Cli) -> Result { format, } => cmd_check_sources(&cli, *update, *apply, *strict, format), CheckAction::AiDefectsOpen { format } => cmd_check_ai_defects_open(&cli, format), + CheckAction::Docs { strict, format } => cmd_check_docs(&cli, *strict, format), }, #[cfg(feature = "wasm")] Command::Import { @@ -14200,6 +14221,42 @@ fn cmd_check_sources( Ok(firing == 0) } +/// `rivet check docs` — enumerate the doc scanner's per-path verdicts. +/// +/// See [`check::docs`] for the JSON contract. Iterates the project's +/// configured `docs:` entries, runs `scan_documents` per directory, +/// and prints the flattened enumeration. `--strict` exits non-zero +/// when any entry is `skipped` (the scanner declined a file and the +/// user did not allowlist it). +fn cmd_check_docs(cli: &Cli, strict: bool, format: &str) -> Result { + validate_format(format, &["text", "json"])?; + let config_path = cli.project.join("rivet.yaml"); + if !config_path.exists() { + let project_dir = + std::fs::canonicalize(&cli.project).unwrap_or_else(|_| cli.project.clone()); + anyhow::bail!( + "no rivet.yaml found in {}\n\nTo initialize a new project, run: rivet init", + project_dir.display() + ); + } + let config = rivet_core::load_project_config(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + + let report = check::docs::compute(&cli.project, &config.docs)?; + + if format == "json" { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print!("{}", check::docs::render_text(&report)); + } + + if strict { + Ok(check::docs::strict_passes(&report)) + } else { + Ok(true) + } +} + struct ProjectContext { config: ProjectConfig, store: Store, diff --git a/rivet-cli/tests/docs_check.rs b/rivet-cli/tests/docs_check.rs new file mode 100644 index 00000000..ff5d8dad --- /dev/null +++ b/rivet-cli/tests/docs_check.rs @@ -0,0 +1,289 @@ +// SAFETY-REVIEW (SCRC Phase 1, DD-058): Integration test / bench code. +// Tests legitimately use unwrap/expect/panic/assert-indexing patterns +// because a test failure should panic with a clear stack. Blanket-allow +// the Phase 1 restriction lints at crate scope; real risk analysis for +// these lints is carried by production code in rivet-core/src and +// rivet-cli/src, not by the test harnesses. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::wildcard_enum_match_arm, + clippy::match_wildcard_for_single_variants, + clippy::panic, + clippy::todo, + clippy::unimplemented, + clippy::dbg_macro, + clippy::print_stdout, + clippy::print_stderr +)] + +//! End-to-end coverage for `rivet check docs` — the dedicated oracle +//! that enumerates every candidate path the doc scanner considered and +//! tags each `loaded` / `skipped ()` / `excluded ()`. +//! +//! Verifies the issue #540 kill-criterion against synthetic fixtures +//! with the same three failure modes as the gale skip cases: +//! * no YAML frontmatter +//! * frontmatter missing the required `id` field +//! * file covered by a `docs[].exclude` glob + +use std::process::Command; + +fn rivet_bin() -> std::path::PathBuf { + if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") { + return std::path::PathBuf::from(bin); + } + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + workspace_root.join("target").join("debug").join("rivet") +} + +/// Lay out a minimal rivet project with four candidate docs: +/// * `good.md` — well-formed frontmatter (loaded) +/// * `no-frontmatter.md` — plain markdown, no `---` (skipped) +/// * `missing-id.md` — frontmatter present but `id:` field missing (skipped) +/// * `generated.md` — would skip on no-frontmatter, here for excluding +/// +/// `docs_section` is the literal `docs:` block to splice into rivet.yaml, +/// so each test can vary the allowlist independently. +fn fixture(dir: &std::path::Path, docs_section: &str) { + std::fs::write( + dir.join("rivet.yaml"), + format!( + "project:\n \ + name: test\n \ + version: \"0.1.0\"\n \ + schemas: []\n\ + sources:\n \ + - path: artifacts\n \ + format: generic-yaml\n\ + {docs_section}", + ), + ) + .expect("write rivet.yaml"); + + let artifacts = dir.join("artifacts"); + std::fs::create_dir_all(&artifacts).expect("create artifacts/"); + + let docs = dir.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs/"); + + // Loaded + std::fs::write( + docs.join("good.md"), + "---\nid: D-1\ntitle: Good\ntype: document\n---\n\nbody\n", + ) + .expect("write good.md"); + + // Skipped — no frontmatter (matches gale's mcuboot-coverage-analysis.md) + std::fs::write( + docs.join("no-frontmatter.md"), + "# No frontmatter here\n\nplain markdown\n", + ) + .expect("write no-frontmatter.md"); + + // Skipped — frontmatter present but missing required `id` field + // (matches gale's release-plan.md) + std::fs::write( + docs.join("missing-id.md"), + "---\ntitle: Missing ID\ntype: document\n---\n\nbody\n", + ) + .expect("write missing-id.md"); + + // Either excluded (when allowlisted) or skipped (when not). Used to + // mirror the third gale case (wasm-module-distribution.md) and to + // demonstrate that an exclude glob keeps `--strict` green. + std::fs::write( + docs.join("generated.md"), + "# Generated\n\nno frontmatter, generated artifact\n", + ) + .expect("write generated.md"); +} + +#[test] +fn cmd_check_docs_enumerates_loaded_skipped_excluded() { + let tmp = tempfile::tempdir().expect("tempdir"); + fixture( + tmp.path(), + "docs:\n - path: docs\n exclude:\n - \"generated.md\"\n", + ); + + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "check", + "docs", + "--format", + "json", + ]) + .output() + .expect("run rivet check docs"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("bad JSON: {e}\n{stdout}")); + + assert_eq!(v["oracle"], "docs"); + assert_eq!(v["total"], 4); + assert_eq!(v["by_status"]["loaded"], 1); + assert_eq!(v["by_status"]["skipped"], 2); + assert_eq!(v["by_status"]["excluded"], 1); + + let entries = v["entries"].as_array().expect("entries array"); + + let find = |name: &str| { + entries + .iter() + .find(|e| e["path"].as_str().unwrap_or("").ends_with(name)) + .unwrap_or_else(|| panic!("no entry for {name} in {stdout}")) + }; + + assert_eq!(find("good.md")["status"], "loaded"); + + let no_fm = find("no-frontmatter.md"); + assert_eq!(no_fm["status"], "skipped"); + assert_eq!(no_fm["reason"], "no YAML frontmatter"); + + let missing_id = find("missing-id.md"); + assert_eq!(missing_id["status"], "skipped"); + let reason = missing_id["reason"] + .as_str() + .expect("missing-id has reason"); + assert!( + reason.contains("id"), + "missing-id reason should mention the absent `id` field, got: {reason}" + ); + + let generated = find("generated.md"); + assert_eq!(generated["status"], "excluded"); + assert_eq!(generated["reason"], "generated.md"); +} + +#[test] +fn cmd_check_docs_strict_exits_nonzero_when_any_skipped() { + let tmp = tempfile::tempdir().expect("tempdir"); + // No excludes — both no-frontmatter.md and missing-id.md are + // genuine skips that should trip --strict. + fixture(tmp.path(), "docs:\n - docs\n"); + + // Un-strict run: passes despite the skips. + let lenient = Command::new(rivet_bin()) + .args(["--project", tmp.path().to_str().unwrap(), "check", "docs"]) + .output() + .expect("run rivet check docs"); + assert!( + lenient.status.success(), + "non-strict check docs must pass: stderr={}", + String::from_utf8_lossy(&lenient.stderr) + ); + + // Strict run: any skipped entry trips exit 1. + let strict = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "check", + "docs", + "--strict", + ]) + .output() + .expect("run rivet check docs --strict"); + assert!( + !strict.status.success(), + "strict check docs must fail when any candidate is skipped" + ); +} + +#[test] +fn cmd_check_docs_strict_passes_when_skipped_files_excluded() { + let tmp = tempfile::tempdir().expect("tempdir"); + // Allowlist every file the scanner would have skipped. Only good.md + // remains as a loaded entry; generated.md, no-frontmatter.md, and + // missing-id.md all flip from `skipped` to `excluded`. + fixture( + tmp.path(), + "docs:\n - path: docs\n exclude:\n \ + - \"no-frontmatter.md\"\n \ + - \"missing-id.md\"\n \ + - \"generated.md\"\n", + ); + + let out = Command::new(rivet_bin()) + .args([ + "--project", + tmp.path().to_str().unwrap(), + "check", + "docs", + "--strict", + "--format", + "json", + ]) + .output() + .expect("run rivet check docs --strict"); + + assert!( + out.status.success(), + "strict check docs must pass when all skips are allowlisted: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + + let v: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid JSON"); + assert_eq!(v["by_status"]["skipped"], 0); + assert_eq!(v["by_status"]["excluded"], 3); + assert_eq!(v["by_status"]["loaded"], 1); +} + +#[test] +fn cmd_check_docs_text_format_is_human_readable() { + let tmp = tempfile::tempdir().expect("tempdir"); + fixture( + tmp.path(), + "docs:\n - path: docs\n exclude:\n - \"generated.md\"\n", + ); + + let out = Command::new(rivet_bin()) + .args(["--project", tmp.path().to_str().unwrap(), "check", "docs"]) + .output() + .expect("run rivet check docs"); + let stdout = String::from_utf8_lossy(&out.stdout); + + // One line per candidate. + assert!( + stdout.contains("loaded: "), + "missing loaded line:\n{stdout}" + ); + assert!( + stdout.contains("skipped: "), + "missing skipped line:\n{stdout}" + ); + assert!( + stdout.contains("excluded: "), + "missing excluded line:\n{stdout}" + ); + assert!(stdout.contains("good.md"), "missing good.md:\n{stdout}"); + assert!( + stdout.contains("no-frontmatter.md"), + "missing no-frontmatter.md:\n{stdout}" + ); + assert!( + stdout.contains("no YAML frontmatter"), + "missing reason text:\n{stdout}" + ); + assert!( + stdout.contains("generated.md"), + "missing generated.md:\n{stdout}" + ); + + // Trailing summary line. + assert!( + stdout.contains("Total: 4 (loaded: 1, skipped: 2, excluded: 1)"), + "missing summary line:\n{stdout}" + ); +} diff --git a/rivet-core/src/document.rs b/rivet-core/src/document.rs index 9002ffb2..b21773e5 100644 --- a/rivet-core/src/document.rs +++ b/rivet-core/src/document.rs @@ -235,6 +235,121 @@ impl ScanReport { } } +/// Per-path verdict from a docs scan. See [`scan_documents`]. +/// +/// The scanner classifies every `.md` / `.markdown` candidate it finds +/// into exactly one of three buckets. The `Skipped` and `Excluded` +/// variants carry a human-readable reason / matching glob so callers +/// can present an actionable enumeration (e.g. `rivet check docs`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocScanStatus { + /// File parsed cleanly into a [`Document`]. + Loaded, + /// File was declined: either no leading `---` frontmatter or a + /// frontmatter parse error. The string is the same reason the + /// stderr-warning form prints (`"no YAML frontmatter"` or the + /// serde error message). + Skipped(String), + /// File matched an `exclude:` glob. The string is the matching + /// pattern (verbatim from `docs[].exclude`) so the caller can show + /// which allowlist entry covered it. + Excluded(String), +} + +/// One row of the per-path enumeration produced by [`scan_documents`]. +#[derive(Debug, Clone)] +pub struct ScannedDoc { + /// Absolute path to the candidate file on disk. + pub path: PathBuf, + /// Verdict for this file. + pub status: DocScanStatus, +} + +/// Walk `dir` and classify every `.md` / `.markdown` candidate without +/// emitting stderr warnings. +/// +/// This is the per-path form of [`load_documents_with_report`]: it +/// returns a `Vec` so callers can render the full +/// enumeration (loaded / skipped-with-reason / excluded-by-glob). The +/// warning-printing convenience that `load_documents_with_report` keeps +/// for `rivet validate` is layered on top of this function; the byte +/// shape of those warnings is preserved. +/// +/// Iteration order, glob semantics, and the set of files considered +/// match `load_documents_with_report` exactly — only the side-channel +/// (stderr warnings vs. structured return) differs. +pub fn scan_documents(dir: &Path, exclude: &[String]) -> Result, Error> { + let mut out = Vec::new(); + if !dir.is_dir() { + return Ok(out); + } + + // Pre-compile each exclude pattern into a regex once. An invalid + // pattern emits a one-shot stderr warning (same as the streaming + // form) and is then ignored — we don't fail the whole scan over a + // malformed allowlist entry. + let compiled: Vec<(String, regex::Regex)> = exclude + .iter() + .filter_map(|pat| match glob_to_regex(pat) { + Ok(re) => Some((pat.clone(), re)), + Err(e) => { + eprintln!("warning: invalid docs exclude pattern {pat:?}: {e}"); + None + } + }) + .collect(); + + 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 rel = path.strip_prefix(dir).unwrap_or(&path); + let rel_str = rel.to_string_lossy(); + if let Some((pat, _)) = compiled.iter().find(|(_, re)| re.is_match(&rel_str)) { + out.push(ScannedDoc { + path: path.clone(), + status: DocScanStatus::Excluded(pat.clone()), + }); + continue; + } + + let content = std::fs::read_to_string(&path) + .map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + + if !content.starts_with("---") { + out.push(ScannedDoc { + path: path.clone(), + status: DocScanStatus::Skipped("no YAML frontmatter".to_string()), + }); + continue; + } + + match parse_document(&content, Some(&path)) { + Ok(_) => out.push(ScannedDoc { + path: path.clone(), + status: DocScanStatus::Loaded, + }), + Err(e) => out.push(ScannedDoc { + path: path.clone(), + status: DocScanStatus::Skipped(e.to_string()), + }), + } + } + + Ok(out) +} + /// Load all `.md` files from a directory as documents. /// /// Convenience wrapper that prints warnings to stderr and discards the