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
110 changes: 108 additions & 2 deletions rivet-cli/src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ const TOPICS: &[DocTopic] = &[
category: "Reference",
content: DOCS_COVERAGE_DOC,
},
DocTopic {
slug: "docs-check",
title: "rivet docs check — invariant engine and embedded-doc checks",
category: "Reference",
content: DOCS_CHECK_DOC,
},
];

/// Return all registered topic slugs in declaration order.
Expand All @@ -270,6 +276,16 @@ pub fn topic_slugs() -> Vec<&'static str> {
TOPICS.iter().map(|t| t.slug).collect()
}

/// Return every registered topic as `(slug, body)` pairs.
///
/// Used by the embedded-doc invariants in `rivet docs check`
/// (`EmbeddedVersionLiterals`, `EmbeddedFlagReferences`,
/// `EmbeddedTodoMarkers`) to scan the strings shipped in the binary
/// — the markdown scanner only sees files on disk.
pub fn topic_bodies() -> Vec<(&'static str, &'static str)> {
TOPICS.iter().map(|t| (t.slug, t.content)).collect()
}

/// True iff a topic with this slug is registered.
#[allow(dead_code)]
pub fn has_topic(slug: &str) -> bool {
Expand Down Expand Up @@ -1604,11 +1620,15 @@ that follow the spec strictly will reject `tools/list` until they see it.
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {...}, "resources": {...}},
"serverInfo": {"name": "rivet", "version": "0.5.0"}
"serverInfo": {"name": "rivet", "version": "<rmcp-version>"}
}
}
```

The `version` field reflects the underlying [rmcp](https://crates.io/crates/rmcp)
crate version — not rivet's. It changes with `rmcp` upgrades and is not
tied to the rivet release line.

3. **Client → server**: `notifications/initialized` notification. **No
id, no response.** This is the gate — the server treats it as the
client's signal that it is ready to receive tool calls.
Expand Down Expand Up @@ -2031,7 +2051,7 @@ artifacts via the link graph.
## Usage

rivet impact --since main # Compare against main branch
rivet impact --since v0.5.0 # Compare against a tag
rivet impact --since vX.Y.Z # Compare against a release tag
rivet impact --baseline ./old/ # Compare against a directory
rivet impact --since HEAD~5 --depth 2 # Limit traversal depth
rivet impact --since main --format json
Expand Down Expand Up @@ -2871,3 +2891,89 @@ ship no user-facing documentation surface:
Add new exemptions only when there's a real reason — the goal of the gate
is to surface gaps, not paper over them.
"#;

const DOCS_CHECK_DOC: &str = r#"# rivet docs check — invariant engine

`rivet docs check` runs a set of doc-vs-reality invariants over the
project's markdown docs and the binary's embedded `rivet docs <topic>`
bodies. Each invariant emits a typed violation describing the claim
(what the doc says) and reality (what the code or store says). Used as
a CI gate, it catches drift before users do.

## Quick start

```
rivet docs check # text report
rivet docs check --format json # JSON report
rivet docs check --fix # apply auto-fixes (currently
# only ancillary package.json
# version bumps)
```

## Markdown invariants

These invariants scan files on disk under `docs/`, plus README.md,
CHANGELOG.md, AGENTS.md, and CLAUDE.md (and any roots configured via
`rivet.yaml` `docs:`).

| Invariant | Catches |
|--------------------------|------------------------------------------------------------|
| `SubcommandReferences` | `rivet <word>` prose mentions for non-existent subcommands |
| `EmbedTokenReferences` | `{{name:...}}` for unknown embed kinds |
| `VersionConsistency` | Future versions mentioned in prose; package.json drift |
| `ArtifactCounts` | "N requirements" claims with no `{{stats}}` or AUDIT marker |
| `SchemaReferences` | `schemas/foo.yaml` references that resolve nowhere |
| `SoftGateHonesty` | "X enforced in CI" prose for `continue-on-error: true` jobs |
| `ConfigExampleFreshness` | ```yaml fenced blocks that fail to parse |
| `ArtifactIdValidity` | `REQ-NNN`-shaped IDs not in the artifact store |
| `MigrationConflict` | rebase-style markers in artifact YAML (`<<<<<<<` etc.) |

## Embedded-doc invariants

Three invariants scan the strings shipped in the `rivet docs <topic>`
registry — the bodies the binary prints, not files on disk. The
markdown scanner does not see those strings, so without these checks
stale literals shipped in a release are invisible until a user
complains.

### `EmbeddedVersionLiterals`

Every `vX.Y.Z` / `X.Y.Z` token in a topic body must equal the workspace
version or appear in `rivet.yaml` `docs-check.allowed-version-literals`.
Use the allowlist for legitimate references:

```yaml
docs-check:
allowed-version-literals:
- "1.3.0" # rmcp crate pin
- "0.1.0" # shipped schema header version
```

Entries without a leading `v` also match the `v`-prefixed form, so a
single `0.1.0` covers both shapes.

### `EmbeddedFlagReferences`

Every `rivet <subcmd> --<flag>` token in a topic body must reference a
flag declared on that subcommand in the live clap tree. Walks
parent-up so a flag declared on the root or on an intermediate
subcommand resolves correctly. When the *subcommand* itself is
unknown, the violation is left to `SubcommandReferences` to report —
not double-counted here.

### `EmbeddedTodoMarkers`

Embedded topic bodies must not contain `TODO`, `FIXME`, or `XXX`
markers. Author notes belong in commits or issue trackers, not in
user-facing doc strings shipped in release binaries.

## CI integration

```yaml
- name: Doc-check
run: cargo run --release -p rivet-cli -- docs check
```

The gate exits non-zero on any violation. Run with `--format json` for
machine-readable output (the same envelope the dashboard ingests).
"#;
80 changes: 78 additions & 2 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6084,7 +6084,7 @@ fn cmd_export_gherkin(
// Build .feature file
let mut feature = String::new();
feature.push_str(&format!(
"# Generated from {} by rivet export --gherkin\n",
"# Generated from {} by rivet export --format gherkin\n",
art.id
));
feature.push_str(&format!("Feature: {} — {}\n", art.id, art.title));
Expand Down Expand Up @@ -7067,7 +7067,9 @@ fn cmd_docs(
/// Run `rivet docs check` — assert documentation matches reality.
fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result<bool> {
use clap::CommandFactory;
use rivet_core::doc_check::{DocCheckContext, apply_fixes, default_invariants, run_all};
use rivet_core::doc_check::{
DocCheckContext, EmbeddedTopic, apply_fixes, default_invariants, run_all,
};
use std::collections::BTreeSet;

validate_format(format, &["text", "json"])?;
Expand Down Expand Up @@ -7111,6 +7113,11 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result<bool> {
.collect()
})
.unwrap_or_default();
let allowed_version_literals: BTreeSet<String> = project_config
.as_ref()
.and_then(|c| c.docs_check.as_ref())
.map(|d| d.allowed_version_literals.iter().cloned().collect())
.unwrap_or_default();

// 1. Collect docs (honoring per-root `exclude:` allowlists).
let (docs, scan_summary) =
Expand Down Expand Up @@ -7165,6 +7172,23 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result<bool> {
let ci_path = project_root.join(".github/workflows/ci.yml");
let ci_yaml_owned = std::fs::read_to_string(&ci_path).ok();

// 7. Build the embedded-topic body list (drives the Embedded* invariants).
let embedded_topics: Vec<EmbeddedTopic> = docs::topic_bodies()
.into_iter()
.map(|(slug, body)| EmbeddedTopic {
slug: slug.to_string(),
body: body.to_string(),
})
.collect();

// 8. Build the subcommand → long-flag map by walking the live clap tree.
// Keyed on the slash-separated path (e.g. `schema/show`) so the
// EmbeddedFlagReferences invariant can resolve nested subcommand
// invocations. Each entry includes the inherited globals from the
// root command so docs that reference `--format` on a leaf still
// pass.
let subcommand_flags = build_subcommand_flag_map(&Cli::command());

let ctx = DocCheckContext {
project_root: &project_root,
docs: &docs,
Expand All @@ -7175,6 +7199,9 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result<bool> {
ci_yaml: ci_yaml_owned.as_deref(),
external_namespaces: &external_namespaces,
ignore_patterns: &ignore_patterns,
embedded_topics: &embedded_topics,
subcommand_flags: &subcommand_flags,
allowed_version_literals: &allowed_version_literals,
};

let invariants = default_invariants();
Expand Down Expand Up @@ -7391,6 +7418,55 @@ fn walk_subcommand(
}
}

/// Walk every subcommand in the clap tree and collect the set of long
/// flags declared on each path. The map is keyed on the slash-separated
/// path (e.g. `schema/show` → {`--format`}). The entry for each
/// subcommand also seeds the *root-level* globals (those declared on
/// the top-level `Cli`) so docs that reference, say, `rivet validate
/// --project ...` are not flagged.
fn build_subcommand_flag_map(
root: &clap::Command,
) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
let mut out = std::collections::BTreeMap::new();
let global_flags: std::collections::BTreeSet<String> = root
.get_arguments()
.filter_map(|a| a.get_long().map(str::to_string))
.collect();
for sub in root.get_subcommands() {
walk_flag_subcommand(sub, "", &global_flags, &mut out);
}
out
}

fn walk_flag_subcommand(
cmd: &clap::Command,
parent_path: &str,
global_flags: &std::collections::BTreeSet<String>,
out: &mut std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
) {
let name = cmd.get_name();
let path = if parent_path.is_empty() {
name.to_string()
} else {
format!("{parent_path}/{name}")
};
let mut flags: std::collections::BTreeSet<String> = cmd
.get_arguments()
.filter_map(|a| a.get_long().map(str::to_string))
.collect();
// `--help` and `--version` are clap built-ins and always accepted.
flags.insert("help".to_string());
flags.insert("version".to_string());
// Inherit the root-level globals (e.g. `--project`, `--verbose`).
for f in global_flags {
flags.insert(f.clone());
}
out.insert(path.clone(), flags);
for child in cmd.get_subcommands() {
walk_flag_subcommand(child, &path, global_flags, out);
}
}

/// Compute the coverage rows for a given clap tree and topic-slug set.
/// Pulled into its own function so the unit tests can exercise it without
/// shelling out to the full CLI.
Expand Down
4 changes: 2 additions & 2 deletions rivet-cli/src/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ npm install -g @pulseengine/rivet
rivet --version
```

Expected: a line of the form `rivet 0.5.0` (or higher). Non-zero exit
means the binary is not on PATH.
Expected: a line starting with `rivet ` followed by a version. Non-zero
exit means the binary is not on PATH.

---

Expand Down
Loading
Loading