Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions rivet-cli/src/check/docs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//! `rivet check docs` — enumerate every candidate path the doc scanner
//! considered and tag each `loaded` / `skipped (<reason>)` /
//! `excluded (<glob>)`.
//!
//! 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<String>,
}

#[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<Entry>,
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<Report> {
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
}
1 change: 1 addition & 0 deletions rivet-cli/src/check/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
57 changes: 57 additions & 0 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<reason>)` / `excluded (<glob>)`.
/// 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 {
Expand Down Expand Up @@ -2499,6 +2519,7 @@ fn run(cli: Cli) -> Result<bool> {
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 {
Expand Down Expand Up @@ -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<bool> {
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,
Expand Down
Loading
Loading