diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 014340e..ffca849 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -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. @@ -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 { @@ -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": ""} } } ``` + 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. @@ -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 @@ -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 ` +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 ` 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 ` +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 --` 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). +"#; diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index f005049..4192139 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -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)); @@ -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 { 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"])?; @@ -7111,6 +7113,11 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result { .collect() }) .unwrap_or_default(); + let allowed_version_literals: BTreeSet = 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) = @@ -7165,6 +7172,23 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result { 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 = 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, @@ -7175,6 +7199,9 @@ fn cmd_docs_check(cli: &Cli, format: &str, fix: bool) -> Result { 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(); @@ -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> { + let mut out = std::collections::BTreeMap::new(); + let global_flags: std::collections::BTreeSet = 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, + out: &mut std::collections::BTreeMap>, +) { + 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 = 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. diff --git a/rivet-cli/src/quickstart.md b/rivet-cli/src/quickstart.md index 09e7721..12f70bc 100644 --- a/rivet-cli/src/quickstart.md +++ b/rivet-cli/src/quickstart.md @@ -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. --- diff --git a/rivet-core/src/doc_check.rs b/rivet-core/src/doc_check.rs index 7450fdd..5cd3cb7 100644 --- a/rivet-core/src/doc_check.rs +++ b/rivet-core/src/doc_check.rs @@ -146,6 +146,19 @@ impl DocFile { } } +/// One embedded `rivet docs ` body, presented to the invariants +/// that scan the embedded set (as opposed to markdown files in the +/// workspace). The `slug` is reported as the violation `file` so the +/// user sees `quickstart:47 [EmbeddedVersionLiterals] ...` rather than +/// a synthetic path. +#[derive(Debug, Clone)] +pub struct EmbeddedTopic { + /// Topic slug (e.g. `quickstart`, `mcp`, `schema/dev`). + pub slug: String, + /// Full topic body as printed by `rivet docs `. + pub body: String, +} + /// Context passed to every invariant. pub struct DocCheckContext<'a> { /// Project root (absolute). @@ -171,6 +184,19 @@ pub struct DocCheckContext<'a> { /// Pre-compiled regex patterns from `docs-check.ignore-patterns`. /// Any ID match that satisfies one of these is skipped. pub ignore_patterns: &'a [regex::Regex], + /// Embedded `rivet docs ` bodies — drives the + /// `Embedded*` invariant family. Empty when the caller does not + /// provide them (e.g. external embedders of the engine). + pub embedded_topics: &'a [EmbeddedTopic], + /// Map from subcommand path (slash-separated, e.g. `schema/show`) + /// to the long-flag set declared for it in clap (e.g. `--format`, + /// `--type`). Drives [`EmbeddedFlagReferences`]; empty disables it. + pub subcommand_flags: &'a BTreeMap>, + /// Versions in [`EmbeddedVersionLiterals`] that are intentionally + /// pinned (e.g. CHANGELOG sections, third-party crate version + /// pins, MCP protocol revisions). Matches against the literal as + /// captured (with or without leading `v`). + pub allowed_version_literals: &'a BTreeSet, } /// One invariant. @@ -401,6 +427,9 @@ pub fn default_invariants() -> Vec> { Box::new(ConfigExampleFreshness), Box::new(ArtifactIdValidity), Box::new(MigrationConflict), + Box::new(EmbeddedVersionLiterals), + Box::new(EmbeddedFlagReferences), + Box::new(EmbeddedTodoMarkers), ] } @@ -1260,6 +1289,202 @@ fn collect_artifact_yaml_files(dir: &Path, out: &mut Vec) { } } +// ──────────────────────────────────────────────────────────────────────── +// Invariant: EmbeddedVersionLiterals +// ──────────────────────────────────────────────────────────────────────── + +/// Scan every `rivet docs ` body for hard-coded `vX.Y.Z` / +/// `X.Y.Z` literals and assert each one is either the workspace version +/// or in the explicit allowlist (CHANGELOG sections, third-party crate +/// pins, MCP protocol revisions, etc.). +/// +/// This is the dual of [`VersionConsistency`], which scans markdown +/// files in the workspace. The embedded topics are not on disk so the +/// markdown scanner skips them; without this invariant, stale literals +/// shipped in `rivet docs ` are invisible until a user complains. +pub struct EmbeddedVersionLiterals; + +impl DocInvariant for EmbeddedVersionLiterals { + fn name(&self) -> &'static str { + "EmbeddedVersionLiterals" + } + + fn check(&self, ctx: &DocCheckContext<'_>) -> Vec { + let mut out = Vec::new(); + if ctx.embedded_topics.is_empty() { + return out; + } + // Match versions surrounded by whitespace, quotes, backticks, + // commas, parens, slashes, colons, or end-of-string. Capture the + // optional leading 'v' so the allowlist works for both `0.5.0` + // and `v0.5.0`. + let re = regex::Regex::new( + "(?:^|[\\s\\[\\(\\{`'\"/,:>])((v?)(\\d+)\\.(\\d+)\\.(\\d+))(?:$|[\\s\\]\\)\\}`'\"/,:.;])", + ) + .unwrap(); + let expected = ctx.workspace_version; + let expected_v = format!("v{expected}"); + for topic in ctx.embedded_topics { + for cap in re.captures_iter(&topic.body) { + let m = cap.get(1).unwrap(); + let raw = m.as_str(); + if raw == expected || raw == expected_v { + continue; + } + // Accept the literal as captured (with or without the + // leading `v`) so users only have to allowlist one form. + let stripped = raw.strip_prefix('v').unwrap_or(raw); + if ctx.allowed_version_literals.contains(raw) + || ctx.allowed_version_literals.contains(stripped) + { + continue; + } + let line = line_for_offset(&topic.body, m.start()); + out.push(Violation { + file: PathBuf::from(format!("rivet docs {}", topic.slug)), + line, + invariant: self.name().to_string(), + claim: format!("embedded literal {raw}"), + reality: format!( + "workspace version is {expected}; allowlist via \ + rivet.yaml docs-check.allowed-version-literals" + ), + auto_fixable: false, + }); + } + } + out + } +} + +// ──────────────────────────────────────────────────────────────────────── +// Invariant: EmbeddedFlagReferences +// ──────────────────────────────────────────────────────────────────────── + +/// For every `rivet --` token in an embedded topic +/// body, assert the flag exists on that subcommand in the clap tree. +/// +/// Drives off [`DocCheckContext::subcommand_flags`], which the CLI +/// populates from `clap::Command`'s long-flag metadata. The map is +/// keyed on the slash-separated subcommand path (e.g. `schema/show`) +/// so nested commands resolve correctly. +pub struct EmbeddedFlagReferences; + +impl DocInvariant for EmbeddedFlagReferences { + fn name(&self) -> &'static str { + "EmbeddedFlagReferences" + } + + fn check(&self, ctx: &DocCheckContext<'_>) -> Vec { + let mut out = Vec::new(); + if ctx.embedded_topics.is_empty() || ctx.subcommand_flags.is_empty() { + return out; + } + // Match `rivet sub [sub2 ...] --flag` where each subcommand + // segment is lowercase letters/digits/dashes and the flag + // captures everything before whitespace, `=`, comma, or backtick. + let re = + regex::Regex::new(r"\brivet((?:[ \t]+[a-z][a-z0-9\-]*)+)[ \t]+--([a-z][a-z0-9\-]*)") + .unwrap(); + for topic in ctx.embedded_topics { + for cap in re.captures_iter(&topic.body) { + let segs_str = cap.get(1).unwrap().as_str(); + let flag = cap.get(2).unwrap().as_str(); + let segs: Vec<&str> = segs_str.split_whitespace().collect(); + if segs.is_empty() { + continue; + } + // Walk from the deepest path up; a flag declared on a + // parent (e.g. global `--format`) is acceptable on a + // child invocation. + let mut found = false; + let mut top_known = false; + for cut in (1..=segs.len()).rev() { + let path = segs[..cut].join("/"); + if let Some(flags) = ctx.subcommand_flags.get(&path) { + top_known = true; + if flags.contains(flag) { + found = true; + break; + } + } + } + // If we could not find the subcommand at all, defer to + // SubcommandReferences (or the coverage gate). Only + // flag a real "flag missing on known subcommand" case + // so we don't double-report. + if !top_known || found { + continue; + } + let m = cap.get(0).unwrap(); + let line = line_for_offset(&topic.body, m.start()); + let path = segs.join("/"); + out.push(Violation { + file: PathBuf::from(format!("rivet docs {}", topic.slug)), + line, + invariant: self.name().to_string(), + claim: format!("rivet {} --{flag}", segs.join(" ")), + reality: format!( + "--{flag} is not declared on `rivet {}`", + path.replace('/', " ") + ), + auto_fixable: false, + }); + } + } + out + } +} + +// ──────────────────────────────────────────────────────────────────────── +// Invariant: EmbeddedTodoMarkers +// ──────────────────────────────────────────────────────────────────────── + +/// Embedded `rivet docs ` bodies must not ship `TODO`, `FIXME`, +/// or `XXX` markers — those are author notes that should be resolved +/// before landing, not user-facing prose. +pub struct EmbeddedTodoMarkers; + +impl DocInvariant for EmbeddedTodoMarkers { + fn name(&self) -> &'static str { + "EmbeddedTodoMarkers" + } + + fn check(&self, ctx: &DocCheckContext<'_>) -> Vec { + let mut out = Vec::new(); + if ctx.embedded_topics.is_empty() { + return out; + } + let re = + regex::Regex::new("(?:^|[\\s\\(`'\"])(TODO|FIXME|XXX)(?:[\\s:\\)`'\"\\.,]|$)").unwrap(); + for topic in ctx.embedded_topics { + let body = topic.body.as_bytes(); + for cap in re.captures_iter(&topic.body) { + let m = cap.get(1).unwrap(); + // Skip when the marker is wrapped in inline backticks + // (e.g. ``TODO``) — those are meta-references in prose + // describing the marker, not author notes. + let prev = m.start().checked_sub(1).and_then(|i| body.get(i)).copied(); + let next = body.get(m.end()).copied(); + if prev == Some(b'`') && next == Some(b'`') { + continue; + } + let line = line_for_offset(&topic.body, m.start()); + out.push(Violation { + file: PathBuf::from(format!("rivet docs {}", topic.slug)), + line, + invariant: self.name().to_string(), + claim: format!("contains {}", m.as_str()), + reality: "embedded topic ships an author marker; resolve or drop the line" + .to_string(), + auto_fixable: false, + }); + } + } + out + } +} + // ──────────────────────────────────────────────────────────────────────── // Auto-fix // ──────────────────────────────────────────────────────────────────────── @@ -1329,9 +1554,17 @@ mod tests { ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), } } + static TEST_EMPTY_FLAG_MAP: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + static TEST_EMPTY_VERSION_SET: std::sync::OnceLock> = + std::sync::OnceLock::new(); + fn known_cmds(names: &[&str]) -> BTreeSet { names.iter().map(|s| s.to_string()).collect() } @@ -1545,6 +1778,9 @@ jobs: ci_yaml: Some(ci), external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = SoftGateHonesty.check(&ctx); assert_eq!(v.len(), 1); @@ -1575,6 +1811,9 @@ jobs: ci_yaml: Some(ci), external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = SoftGateHonesty.check(&ctx); assert!(v.is_empty(), "got: {v:?}"); @@ -1626,6 +1865,9 @@ jobs: ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); assert_eq!(v.len(), 1); @@ -1659,6 +1901,9 @@ jobs: ci_yaml: None, external_namespaces: &exempted, ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); assert!(v.is_empty(), "external IDs should be exempted: {v:?}"); @@ -1688,6 +1933,9 @@ jobs: ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); let claims: Vec<&str> = v.iter().map(|x| x.claim.as_str()).collect(); @@ -1717,6 +1965,9 @@ jobs: ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); assert!(v.is_empty(), "got: {v:?}"); @@ -1744,6 +1995,9 @@ jobs: ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); assert_eq!(v.len(), 1); @@ -1769,11 +2023,228 @@ jobs: ci_yaml: None, external_namespaces: &[], ignore_patterns: &[], + embedded_topics: &[], + subcommand_flags: TEST_EMPTY_FLAG_MAP.get_or_init(BTreeMap::new), + allowed_version_literals: TEST_EMPTY_VERSION_SET.get_or_init(BTreeSet::new), }; let v = ArtifactIdValidity.check(&ctx); assert!(v.is_empty(), "got: {v:?}"); } + // ── EmbeddedVersionLiterals ──────────────────────────────────────── + + fn embedded_topic(slug: &str, body: &str) -> EmbeddedTopic { + EmbeddedTopic { + slug: slug.to_string(), + body: body.to_string(), + } + } + + fn embedded_ctx<'a>( + topics: &'a [EmbeddedTopic], + version: &'a str, + flags: &'a BTreeMap>, + allowed_versions: &'a BTreeSet, + empty_docs: &'a [DocFile], + empty_subs: &'a BTreeSet, + empty_embeds: &'a BTreeSet, + ) -> DocCheckContext<'a> { + DocCheckContext { + project_root: Path::new("."), + docs: empty_docs, + known_subcommands: empty_subs, + known_embeds: empty_embeds, + workspace_version: version, + store: None, + ci_yaml: None, + external_namespaces: &[], + ignore_patterns: &[], + embedded_topics: topics, + subcommand_flags: flags, + allowed_version_literals: allowed_versions, + } + } + + #[test] + fn embedded_version_literals_flags_stale_v_prefixed() { + let topics = vec![embedded_topic( + "quickstart", + "Expected: a line of the form `rivet 0.5.0` (or higher).", + )]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedVersionLiterals.check(&ctx); + assert_eq!(v.len(), 1, "got: {v:?}"); + assert!(v[0].claim.contains("0.5.0")); + assert_eq!(v[0].file, PathBuf::from("rivet docs quickstart")); + } + + #[test] + fn embedded_version_literals_accepts_workspace_version() { + let topics = vec![embedded_topic("topic", "rivet 0.7.0 is current.")]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedVersionLiterals.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + #[test] + fn embedded_version_literals_accepts_allowlisted_literals() { + // The MCP doc references protocol revision "2024-11-05" and the + // rmcp crate version "1.3.0" — both are pinned to upstream, not + // rivet's own release line. + let topics = vec![embedded_topic( + "mcp", + "protocolVersion: 2024-11-05; rmcp 1.3.0 transport.", + )]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let mut allow = BTreeSet::new(); + allow.insert("1.3.0".to_string()); + // 2024-11-05 is a date, not an X.Y.Z, so the regex won't match + // it — the allowlist only needs the rmcp pin. + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedVersionLiterals.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + // ── EmbeddedFlagReferences ───────────────────────────────────────── + + #[test] + fn embedded_flag_references_flags_missing_flag() { + // `rivet validate --bogus` references a flag that does not exist + // on the validate subcommand. + let topics = vec![embedded_topic("cli", "Run `rivet validate --bogus` to ...")]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let mut flags = BTreeMap::new(); + let mut validate_flags = BTreeSet::new(); + validate_flags.insert("format".to_string()); + flags.insert("validate".to_string(), validate_flags); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedFlagReferences.check(&ctx); + assert_eq!(v.len(), 1, "got: {v:?}"); + assert!(v[0].claim.contains("--bogus")); + } + + #[test] + fn embedded_flag_references_accepts_known_flag() { + let topics = vec![embedded_topic("cli", "Run `rivet validate --format json`.")]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let mut flags = BTreeMap::new(); + let mut validate_flags = BTreeSet::new(); + validate_flags.insert("format".to_string()); + flags.insert("validate".to_string(), validate_flags); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedFlagReferences.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + #[test] + fn embedded_flag_references_resolves_nested_subcommand() { + // `rivet schema show --format json` — the flag lives on the + // nested `schema/show` path. The walker should resolve. + let topics = vec![embedded_topic( + "cli", + "Run `rivet schema show sw-req --format json`.", + )]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let mut flags = BTreeMap::new(); + let mut show_flags = BTreeSet::new(); + show_flags.insert("format".to_string()); + flags.insert("schema/show".to_string(), show_flags); + flags.insert("schema".to_string(), BTreeSet::new()); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedFlagReferences.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + #[test] + fn embedded_flag_references_skips_unknown_subcommand() { + // `rivet bogus --foo` — subcommand is unknown, so this is the + // SubcommandReferences invariant's job, not ours. + let topics = vec![embedded_topic("cli", "Run `rivet bogus --foo`.")]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedFlagReferences.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + // ── EmbeddedTodoMarkers ──────────────────────────────────────────── + + #[test] + fn embedded_todo_markers_flag_each_marker() { + let topics = vec![embedded_topic( + "schema/dev", + "See docs/agent-pipelines.md (TODO) for the full spec.\n\ + FIXME: rewrite this section.\n\ + XXX hack: replace with a real example.", + )]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedTodoMarkers.check(&ctx); + assert_eq!(v.len(), 3, "got: {v:?}"); + let claims: Vec<&str> = v.iter().map(|x| x.claim.as_str()).collect(); + assert!(claims.iter().any(|c| c.contains("TODO"))); + assert!(claims.iter().any(|c| c.contains("FIXME"))); + assert!(claims.iter().any(|c| c.contains("XXX"))); + } + + #[test] + fn embedded_todo_markers_pass_clean_topic() { + let topics = vec![embedded_topic("clean", "All of this is fine. No markers.")]; + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + let v = EmbeddedTodoMarkers.check(&ctx); + assert!(v.is_empty(), "got: {v:?}"); + } + + #[test] + fn embedded_invariants_disabled_without_topics() { + // No topics provided — every embedded invariant is a no-op so + // existing callers that don't populate the field still pass. + let topics: Vec = Vec::new(); + let docs: Vec = Vec::new(); + let subs = BTreeSet::new(); + let embeds = BTreeSet::new(); + let flags = BTreeMap::new(); + let allow = BTreeSet::new(); + let ctx = embedded_ctx(&topics, "0.7.0", &flags, &allow, &docs, &subs, &embeds); + assert!(EmbeddedVersionLiterals.check(&ctx).is_empty()); + assert!(EmbeddedFlagReferences.check(&ctx).is_empty()); + assert!(EmbeddedTodoMarkers.check(&ctx).is_empty()); + } + // ── Engine smoke ──────────────────────────────────────────────────── #[test] diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index 82ac8fd..931fc27 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -403,6 +403,20 @@ pub struct DocsCheckConfig { /// matched ID text and skip the violation when any one matches. #[serde(default, rename = "ignore-patterns")] pub ignore_patterns: Vec, + /// Version literals that the `EmbeddedVersionLiterals` invariant + /// should accept even when they differ from the workspace version. + /// Use for legitimate references (third-party crate version pins, + /// MCP protocol revisions, historical CHANGELOG dates). Match is + /// on the literal as captured (with or without leading `v`): + /// + /// ```yaml + /// docs-check: + /// allowed-version-literals: + /// - "2024-11-05" # MCP protocol revision + /// - "1.3.0" # rmcp crate pin + /// ``` + #[serde(default, rename = "allowed-version-literals")] + pub allowed_version_literals: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/rivet.yaml b/rivet.yaml index 3fb33f7..8ea1fea 100644 --- a/rivet.yaml +++ b/rivet.yaml @@ -21,6 +21,34 @@ docs: - docs - arch +# `rivet docs check` configuration +docs-check: + # Version literals shipped in `rivet docs ` bodies that are NOT + # rivet's own version. Without this allowlist, every version field in + # an embedded schema YAML, every example artifact version, etc. would + # be flagged by the EmbeddedVersionLiterals invariant. Match is on the + # literal as captured (with or without leading `v`). + # Match is on the literal as captured. Entries without a `v` prefix + # also match the `v`-prefixed form (so `0.1.0` covers both `0.1.0` + # and `v0.1.0`). + allowed-version-literals: + # Shipped schema version pins (each schema's own `version: "0.1.0"`). + - "0.1.0" + # Example/historical schema versions referenced in topic bodies. + - "0.2.0" + # ASPICE process IDs that look like X.Y.Z (e.g. SYS.2.1.7) — false + # positives from the regex but stable upstream identifiers. + - "2.1.7" + - "2.2.4" + # Supply-chain shipped example artifacts (sample release / SBOM + # versions in supply-chain.yaml). + - "1.0.200" + - "1.2.3" + - "2.1.0" + # rmcp crate version referenced in `rivet docs docs-check` (the + # invariant explainer) — not rivet's own version. + - "1.3.0" + results: results externals: diff --git a/schemas/dev.yaml b/schemas/dev.yaml index 4e7b498..ee2c6ca 100644 --- a/schemas/dev.yaml +++ b/schemas/dev.yaml @@ -134,7 +134,7 @@ artifact-types: description: > Structured acceptance criteria in given/when/then format. Each entry is a string like "Given X, When Y, Then Z". - Use `rivet export --gherkin` to generate .feature files. + Use `rivet export --format gherkin` to generate .feature files. - name: baseline type: string required: false @@ -177,11 +177,10 @@ conditional-rules: required-fields: [description] severity: warning -# Oracle-gated agent pipeline for `rivet close-gaps`. See -# docs/agent-pipelines.md (TODO) for the full spec. This is the simplest -# possible block — one oracle (rivet validate) composed into one -# structural pipeline — so the machinery works end-to-end against the -# dev schema before heavier schemas (ASPICE, ISO 26262, GSN) land. +# Oracle-gated agent pipeline for `rivet close-gaps`. This is the +# simplest possible block — one oracle (rivet validate) composed into +# one structural pipeline — so the machinery works end-to-end against +# the dev schema before heavier schemas (ASPICE, ISO 26262, GSN) land. agent-pipelines: oracles: - id: structural-trace diff --git a/schemas/eu-ai-act.yaml b/schemas/eu-ai-act.yaml index a65ed66..10c111b 100644 --- a/schemas/eu-ai-act.yaml +++ b/schemas/eu-ai-act.yaml @@ -6,7 +6,7 @@ # Applicable from August 2, 2026. Fines up to €35M or 7% global turnover. # # Usage: -# rivet init --schema eu-ai-act +# rivet init --preset eu-ai-act # rivet init --schema eu-ai-act,stpa # with STPA bridge # rivet init --schema eu-ai-act,aspice # with ASPICE bridge #