Skip to content

Commit 1feb996

Browse files
committed
feat(check): add rivet check docs oracle with --format json + --strict (#540)
Enumerates every candidate path the doc scanner considered, tagging each `loaded` / `skipped (<reason>)` / `excluded (<glob>)`. 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<ScannedDoc>` 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
1 parent b376195 commit 1feb996

5 files changed

Lines changed: 628 additions & 0 deletions

File tree

rivet-cli/src/check/docs.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//! `rivet check docs` — enumerate every candidate path the doc scanner
2+
//! considered and tag each `loaded` / `skipped (<reason>)` /
3+
//! `excluded (<glob>)`.
4+
//!
5+
//! Dedicated read-only oracle so the doc-scan status is queryable
6+
//! without a full `rivet validate` pass. Mirrors the conventions of
7+
//! `rivet check sources`: human-text default, `--format json` for
8+
//! mechanical assertions, `--strict` exits non-zero when any candidate
9+
//! is skipped. Explicit `excluded` allowlist matches do **not** trip
10+
//! `--strict` — those are user opt-in, not an oversight.
11+
//!
12+
//! JSON contract on `--format json`:
13+
//! ```json
14+
//! {
15+
//! "oracle": "docs",
16+
//! "entries": [
17+
//! { "path": "docs/foo.md", "status": "loaded" },
18+
//! { "path": "docs/bar.md", "status": "skipped", "reason": "no YAML frontmatter" },
19+
//! { "path": "docs/gen.md", "status": "excluded", "reason": "generated-*.md" }
20+
//! ],
21+
//! "total": 3,
22+
//! "by_status": { "loaded": 1, "skipped": 1, "excluded": 1 }
23+
//! }
24+
//! ```
25+
26+
use std::path::Path;
27+
28+
use anyhow::{Context, Result};
29+
use rivet_core::document::{self, DocScanStatus};
30+
use serde::Serialize;
31+
32+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33+
#[serde(rename_all = "snake_case")]
34+
pub enum EntryStatus {
35+
Loaded,
36+
Skipped,
37+
Excluded,
38+
}
39+
40+
#[derive(Debug, Clone, Serialize)]
41+
pub struct Entry {
42+
/// Path relative to the project root, with forward slashes — stable
43+
/// across platforms so JSON consumers can pattern-match without
44+
/// caring whether the harness ran on Windows.
45+
pub path: String,
46+
pub status: EntryStatus,
47+
/// For `skipped`: the human-readable reason (`"no YAML frontmatter"`
48+
/// or the frontmatter parse-error message). For `excluded`: the
49+
/// matching glob from `docs[].exclude`. Omitted for `loaded`.
50+
#[serde(skip_serializing_if = "Option::is_none")]
51+
pub reason: Option<String>,
52+
}
53+
54+
#[derive(Debug, Default, Serialize)]
55+
pub struct StatusCounts {
56+
pub loaded: usize,
57+
pub skipped: usize,
58+
pub excluded: usize,
59+
}
60+
61+
#[derive(Debug, Serialize)]
62+
pub struct Report {
63+
pub oracle: &'static str,
64+
pub entries: Vec<Entry>,
65+
pub total: usize,
66+
pub by_status: StatusCounts,
67+
}
68+
69+
/// Build the report from the project's configured `docs:` entries.
70+
///
71+
/// Iterates each `DocsEntry`, runs `scan_documents` per directory, and
72+
/// flattens the per-path results into a single ordered list. Paths are
73+
/// normalized relative to `project_root` with forward slashes.
74+
pub fn compute(project_root: &Path, docs: &[rivet_core::model::DocsEntry]) -> Result<Report> {
75+
let mut entries = Vec::new();
76+
let mut by_status = StatusCounts::default();
77+
78+
for entry in docs {
79+
let dir = project_root.join(entry.path());
80+
let scanned = document::scan_documents(&dir, entry.exclude())
81+
.with_context(|| format!("scanning docs from '{}'", entry.path()))?;
82+
83+
for sd in scanned {
84+
let (status, reason) = match sd.status {
85+
DocScanStatus::Loaded => (EntryStatus::Loaded, None),
86+
DocScanStatus::Skipped(r) => (EntryStatus::Skipped, Some(r)),
87+
DocScanStatus::Excluded(p) => (EntryStatus::Excluded, Some(p)),
88+
};
89+
match status {
90+
EntryStatus::Loaded => by_status.loaded += 1,
91+
EntryStatus::Skipped => by_status.skipped += 1,
92+
EntryStatus::Excluded => by_status.excluded += 1,
93+
}
94+
entries.push(Entry {
95+
path: relative_display(project_root, &sd.path),
96+
status,
97+
reason,
98+
});
99+
}
100+
}
101+
102+
let total = entries.len();
103+
Ok(Report {
104+
oracle: "docs",
105+
entries,
106+
total,
107+
by_status,
108+
})
109+
}
110+
111+
/// Render `path` relative to `project_root` using forward slashes, so
112+
/// the JSON shape is stable across platforms. Falls back to the
113+
/// absolute path on a strip failure (canonicalization mismatch).
114+
fn relative_display(project_root: &Path, path: &Path) -> String {
115+
let rel = path.strip_prefix(project_root).unwrap_or(path);
116+
rel.to_string_lossy().replace('\\', "/")
117+
}
118+
119+
/// Render the report as human-readable text: one line per entry plus a
120+
/// trailing summary count.
121+
pub fn render_text(report: &Report) -> String {
122+
use std::fmt::Write;
123+
let mut out = String::new();
124+
if report.entries.is_empty() {
125+
out.push_str("No doc candidates found (check the `docs:` block in rivet.yaml).\n");
126+
return out;
127+
}
128+
for e in &report.entries {
129+
match (&e.status, &e.reason) {
130+
(EntryStatus::Loaded, _) => {
131+
let _ = writeln!(out, "loaded: {}", e.path);
132+
}
133+
(EntryStatus::Skipped, Some(r)) => {
134+
let _ = writeln!(out, "skipped: {}: {}", e.path, r);
135+
}
136+
(EntryStatus::Excluded, Some(p)) => {
137+
let _ = writeln!(out, "excluded: {} (matched: {})", e.path, p);
138+
}
139+
// The reasonless skipped/excluded branches are unreachable
140+
// by construction in `compute`, but render them gracefully
141+
// rather than panic if anyone wires up the type by hand.
142+
(EntryStatus::Skipped, None) => {
143+
let _ = writeln!(out, "skipped: {}", e.path);
144+
}
145+
(EntryStatus::Excluded, None) => {
146+
let _ = writeln!(out, "excluded: {}", e.path);
147+
}
148+
}
149+
}
150+
let _ = writeln!(out);
151+
let _ = writeln!(
152+
out,
153+
"Total: {} (loaded: {}, skipped: {}, excluded: {})",
154+
report.total, report.by_status.loaded, report.by_status.skipped, report.by_status.excluded,
155+
);
156+
out
157+
}
158+
159+
/// Returns `true` on pass / `false` on fail (per the oracle convention).
160+
///
161+
/// Pass: every candidate is either `loaded` or explicitly `excluded`.
162+
/// Fail (only under `--strict`): any candidate is `skipped` — i.e. a
163+
/// file the scanner declined and the user did **not** allowlist.
164+
pub fn strict_passes(report: &Report) -> bool {
165+
report.by_status.skipped == 0
166+
}

rivet-cli/src/check/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
//! The JSON shape is the contract pipelines consume.
2222
2323
pub mod bidirectional;
24+
pub mod docs;
2425
pub mod gaps_json;
2526
pub mod review_signoff;
2627
pub mod sources;

rivet-cli/src/main.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,6 +1871,26 @@ enum CheckAction {
18711871
#[arg(short, long, default_value = "text")]
18721872
format: String,
18731873
},
1874+
1875+
/// Enumerate every candidate path the doc scanner considered and
1876+
/// tag each `loaded` / `skipped (<reason>)` / `excluded (<glob>)`.
1877+
/// Dedicated read-only oracle so the doc-scan status is queryable
1878+
/// without running a full `rivet validate` pass. `--format json`
1879+
/// emits the canonical `{oracle, entries, total, by_status}`
1880+
/// envelope; `--strict` exits non-zero when any entry is `skipped`
1881+
/// (explicit `excluded` allowlist matches do not trip strict).
1882+
Docs {
1883+
/// Read-only audit gate: exit non-zero if any candidate is
1884+
/// `skipped` (the scanner declined the file and it is not on
1885+
/// the `docs[].exclude` allowlist). Files that match an
1886+
/// `exclude:` glob are explicit opt-in and do not trip strict.
1887+
#[arg(long)]
1888+
strict: bool,
1889+
1890+
/// Output format: "text" (default) or "json".
1891+
#[arg(short, long, default_value = "text")]
1892+
format: String,
1893+
},
18741894
}
18751895

18761896
fn main() -> ExitCode {
@@ -2392,6 +2412,7 @@ fn run(cli: Cli) -> Result<bool> {
23922412
format,
23932413
} => cmd_check_sources(&cli, *update, *apply, *strict, format),
23942414
CheckAction::AiDefectsOpen { format } => cmd_check_ai_defects_open(&cli, format),
2415+
CheckAction::Docs { strict, format } => cmd_check_docs(&cli, *strict, format),
23952416
},
23962417
#[cfg(feature = "wasm")]
23972418
Command::Import {
@@ -13603,6 +13624,42 @@ fn cmd_check_sources(
1360313624
Ok(firing == 0)
1360413625
}
1360513626

13627+
/// `rivet check docs` — enumerate the doc scanner's per-path verdicts.
13628+
///
13629+
/// See [`check::docs`] for the JSON contract. Iterates the project's
13630+
/// configured `docs:` entries, runs `scan_documents` per directory,
13631+
/// and prints the flattened enumeration. `--strict` exits non-zero
13632+
/// when any entry is `skipped` (the scanner declined a file and the
13633+
/// user did not allowlist it).
13634+
fn cmd_check_docs(cli: &Cli, strict: bool, format: &str) -> Result<bool> {
13635+
validate_format(format, &["text", "json"])?;
13636+
let config_path = cli.project.join("rivet.yaml");
13637+
if !config_path.exists() {
13638+
let project_dir =
13639+
std::fs::canonicalize(&cli.project).unwrap_or_else(|_| cli.project.clone());
13640+
anyhow::bail!(
13641+
"no rivet.yaml found in {}\n\nTo initialize a new project, run: rivet init",
13642+
project_dir.display()
13643+
);
13644+
}
13645+
let config = rivet_core::load_project_config(&config_path)
13646+
.with_context(|| format!("loading {}", config_path.display()))?;
13647+
13648+
let report = check::docs::compute(&cli.project, &config.docs)?;
13649+
13650+
if format == "json" {
13651+
println!("{}", serde_json::to_string_pretty(&report)?);
13652+
} else {
13653+
print!("{}", check::docs::render_text(&report));
13654+
}
13655+
13656+
if strict {
13657+
Ok(check::docs::strict_passes(&report))
13658+
} else {
13659+
Ok(true)
13660+
}
13661+
}
13662+
1360613663
struct ProjectContext {
1360713664
config: ProjectConfig,
1360813665
store: Store,

0 commit comments

Comments
 (0)