From ee6892105b3874280838f72e9a7abecb1776819d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 30 Apr 2026 08:40:31 +0200 Subject: [PATCH] feat(cli): cited-source --strict, --strict-cited-source-stale, schema migrate --list (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three platform-engineering CLI symmetries from issue #249: B7 — `rivet check sources --strict` is a read-only audit gate. Walks every artifact with a `cited-source`, classifies each as match / drift / missing-hash / read-error / shape-error / stale, and exits non-zero on anything other than match. Mutually exclusive with --update so audit and fix are never the same invocation. Never modifies any YAML. B8 — `rivet validate --strict-cited-source-stale` promotes the previously-Info `cited-source-stale` diagnostic to Error. The stale verdict now fires for missing, unparseable, OR older-than-30-days last-checked timestamps (30d is a hard-coded default; per-schema thresholds remain a follow-up). New helpers: - cited_source::parse_iso8601_utc — chrono-free ISO-8601 parsing - cited_source::classify_staleness — fresh / missing / old / unparseable B9 — `rivet schema migrate --list` enumerates every available recipe (built-in + project-local YAML under /migrations/). Project-local recipes shadow built-ins of the same name. Text and JSON output. Mutually exclusive with target + action flags. New `migrate::list_recipes` helper + `RecipeEntry` / `RecipeOrigin` types. Tests: - B7: integration test asserts clean fixture exits 0; off-disk edit exits 1 without mutating the YAML; --update --apply restores 0. - B7: clap mutex test for --strict + --update. - B8: unit tests cover the staleness classifier and severity promotion; integration test asserts default exit 0 + strict exit 1. - B9: unit tests cover built-in / project-local / shadow precedence; integration tests cover text + JSON output + clap mutex with --apply. Docs (rivet docs schema-cited-sources, rivet docs schema-migrate) updated with the new flags + the audit-gate pattern. Implements: REQ-007, REQ-004 Verifies: REQ-007, REQ-004 Refs: #249 Co-Authored-By: Claude Opus 4.7 (1M context) --- rivet-cli/src/check/sources.rs | 88 ++++- rivet-cli/src/docs.rs | 84 ++++- rivet-cli/src/main.rs | 111 +++++-- rivet-cli/src/migrate_cmd.rs | 78 ++++- rivet-cli/tests/cited_source_integration.rs | 218 ++++++++++++ rivet-core/src/cited_source.rs | 349 +++++++++++++++++++- rivet-core/src/migrate.rs | 175 ++++++++++ 7 files changed, 1060 insertions(+), 43 deletions(-) diff --git a/rivet-cli/src/check/sources.rs b/rivet-cli/src/check/sources.rs index b4954b9..2c3badd 100644 --- a/rivet-cli/src/check/sources.rs +++ b/rivet-cli/src/check/sources.rs @@ -38,7 +38,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use rivet_core::cited_source::{ - self, CheckOutcome, CitedSource, check_cited_source, parse_cited_source, + self, CheckOutcome, CitedSource, STALE_DAYS_DEFAULT, StaleStatus, check_cited_source, + classify_staleness_now, parse_cited_source, }; use rivet_core::model::Artifact; use serde::Serialize; @@ -67,6 +68,42 @@ impl EntryStatus { } } +/// Side-channel staleness report — orthogonal to `EntryStatus` because a +/// `MATCH` entry can still be stale (last-checked is old or missing). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StaleVerdict { + Fresh, + MissingTimestamp, + Old, + Unparseable, +} + +impl StaleVerdict { + fn label(self) -> &'static str { + match self { + StaleVerdict::Fresh => "FRESH", + StaleVerdict::MissingTimestamp => "STALE-MISSING", + StaleVerdict::Old => "STALE-OLD", + StaleVerdict::Unparseable => "STALE-UNPARSEABLE", + } + } + + fn from_status(s: StaleStatus) -> Self { + match s { + StaleStatus::Fresh => StaleVerdict::Fresh, + StaleStatus::Missing => StaleVerdict::MissingTimestamp, + StaleStatus::Old { .. } => StaleVerdict::Old, + StaleStatus::Unparseable => StaleVerdict::Unparseable, + } + } + + /// True for any non-fresh verdict — used by `--strict` exit code. + pub fn is_stale(self) -> bool { + !matches!(self, StaleVerdict::Fresh) + } +} + #[derive(Debug, Clone, Serialize)] pub struct Entry { pub artifact_id: String, @@ -79,6 +116,13 @@ pub struct Entry { pub computed_sha256: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_checked: Option, + /// Side-channel: `last-checked` freshness verdict. Always emitted + /// for kind: file entries; omitted (None) for shape-errors and remote-skipped. + #[serde(skip_serializing_if = "Option::is_none")] + pub stale: Option, + /// Age in days when `stale = Old`. None otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + pub stale_age_days: Option, /// File path on disk for `kind: file` entries (for `--update`). #[serde(skip)] pub source_file: Option, @@ -95,6 +139,7 @@ pub struct StatusCounts { pub read_error: usize, pub skipped_remote: usize, pub shape_error: usize, + pub stale: usize, } #[derive(Debug, Serialize)] @@ -132,6 +177,8 @@ pub fn compute<'a>( stamped_sha256: None, computed_sha256: None, last_checked: None, + stale: None, + stale_age_days: None, source_file: artifact.source_file.clone(), detail: Some(e.to_string()), }); @@ -161,6 +208,24 @@ pub fn compute<'a>( EntryStatus::ShapeError => by_status.shape_error += 1, } + // Compute the staleness side-channel for kind: file. Remote + // kinds skip this — we can't reason about freshness without + // the actual backend. + let (stale, stale_age_days) = if parsed.kind.is_local() { + let s = classify_staleness_now(parsed.last_checked.as_deref(), STALE_DAYS_DEFAULT); + let age = match s { + StaleStatus::Old { age_days } => Some(age_days), + _ => None, + }; + let v = StaleVerdict::from_status(s); + if v.is_stale() { + by_status.stale += 1; + } + (Some(v), age) + } else { + (None, None) + }; + entries.push(Entry { artifact_id: artifact.id.clone(), uri: parsed.uri.clone(), @@ -169,6 +234,8 @@ pub fn compute<'a>( stamped_sha256: parsed.sha256.clone(), computed_sha256: computed, last_checked: parsed.last_checked.clone(), + stale, + stale_age_days, source_file: artifact.source_file.clone(), detail, }); @@ -191,19 +258,31 @@ pub fn render_text(report: &Report) -> String { out.push_str("No artifacts have a cited-source field.\n"); return out; } - let _ = writeln!(out, "{:<14} {:<14} {:<8} URI", "ARTIFACT", "STATUS", "KIND",); + let _ = writeln!( + out, + "{:<14} {:<14} {:<18} {:<8} URI", + "ARTIFACT", "STATUS", "FRESHNESS", "KIND", + ); for e in &report.entries { + let stale_label = e.stale.map(|s| s.label()).unwrap_or("-"); let _ = writeln!( out, - "{:<14} {:<14} {:<8} {}", + "{:<14} {:<14} {:<18} {:<8} {}", e.artifact_id, e.status.label(), + stale_label, e.kind, e.uri ); if let Some(detail) = &e.detail { let _ = writeln!(out, " detail: {detail}"); } + if let Some(age) = e.stale_age_days { + let _ = writeln!( + out, + " last-checked age: {age} day(s) (threshold: {STALE_DAYS_DEFAULT})" + ); + } if let (Some(stamped), Some(computed)) = (&e.stamped_sha256, &e.computed_sha256) { if e.status == EntryStatus::Drift { let _ = writeln!(out, " stamped : {stamped}"); @@ -214,7 +293,7 @@ pub fn render_text(report: &Report) -> String { let _ = writeln!(out); let _ = writeln!( out, - "Total: {} (match: {}, drift: {}, missing-hash: {}, read-error: {}, skipped-remote: {}, shape-error: {})", + "Total: {} (match: {}, drift: {}, missing-hash: {}, read-error: {}, skipped-remote: {}, shape-error: {}, stale: {})", report.total, report.by_status.r#match, report.by_status.drift, @@ -222,6 +301,7 @@ pub fn render_text(report: &Report) -> String { report.by_status.read_error, report.by_status.skipped_remote, report.by_status.shape_error, + report.by_status.stale, ); if report.by_status.skipped_remote > 0 { let _ = writeln!( diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 014340e..dd2d82e 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -1959,7 +1959,10 @@ Rule names emitted by `rivet validate`: - `cited-source-drift` (Warning by default; Error with `--strict-cited-sources`) — sha256 stamp does not match the file on disk, or the stamp is missing entirely. -- `cited-source-stale` (Info) — `last-checked` is missing. +- `cited-source-stale` (Info by default; Error with + `--strict-cited-source-stale`) — `last-checked` is missing, + unparseable, or older than 30 days. The 30-day threshold is a global + default; per-schema overrides are deferred to a follow-up feature. - `cited-source-skipped` (Info, only with `--check-remote-sources`) — remote kind acknowledged but not yet verified (Phase 1). @@ -1987,6 +1990,11 @@ rivet validate # Treat cited-source-drift as a hard failure (CI gate). rivet validate --strict-cited-sources +# Treat cited-source-stale (last-checked > 30d, missing, or unparseable) +# as a hard failure. Pair with --strict-cited-sources for a full audit +# gate. +rivet validate --strict-cited-source-stale + # Phase 2 — flag accepted by Phase 1, no-op for now. rivet validate --check-remote-sources @@ -1994,14 +2002,37 @@ rivet validate --check-remote-sources rivet check sources # list every cited-source + status rivet check sources --update # interactive y/N per drift rivet check sources --update --apply # batch refresh + +# Read-only audit gate. Exit 1 on any drift / missing-hash / read-error +# / shape-error / stale (last-checked > 30d). Never modifies any YAML. +# Mutually exclusive with --update — audit and fix are separate +# invocations so CI never sees a "ran-then-fixed" mutation pattern. +rivet check sources --strict +``` + +## Audit gate pattern (issue #249) + +For platform engineers who want a clean read-only audit gate in CI: + +```bash +# In CI: read-only check. Fails if any cited-source has drifted, is +# missing a hash, has a read error, or has a stale last-checked. +rivet check sources --strict + +# Equivalent gate via `rivet validate`: +rivet validate --strict-cited-sources --strict-cited-source-stale ``` +The `check sources --strict` form emits a richer per-artifact table +(useful for debugging which file drifted); the `validate` form is +preferred when you want a single command to gate the whole project. + ## last-checked semantics -`last-checked` is a stamp, not a verification gate. Phase 1 emits an -`Info`-severity `cited-source-stale` diagnostic when the field is -absent; future phases may add a per-schema staleness threshold (`>30 -days for fast-moving sources`) if real-world demand justifies it. +`last-checked` is a stamp **and** a freshness signal. The +`cited-source-stale` Info diagnostic fires when `last-checked` is +missing, unparseable, or older than the threshold (30 days by default +in v0.7.x). Per-schema thresholds are deferred to a follow-up feature. The `rivet check sources --update --apply` flow always rewrites `last-checked` to the current UTC time when it touches an artifact. @@ -2607,6 +2638,8 @@ the conflict in-place and runs `--continue`, or drops the artifact with ## Quick start ``` +rivet schema migrate --list # enumerate available recipes +rivet schema migrate --list --format json # same, machine-readable rivet schema migrate aspice # plan only (dry-run) rivet schema migrate aspice --apply # apply; pause on first conflict rivet schema migrate aspice --continue # resume after editing markers @@ -2619,6 +2652,47 @@ rivet schema migrate aspice --abort # restore everything from snapshot The default invocation is plan-only and never modifies the project tree. +## Discovering recipes (`--list`) + +`rivet schema migrate --list` walks the recipe registry and prints one +row per available recipe (built-in + every YAML under +`/migrations/`). Project-local recipes shadow built-ins +of the same name. The flag is target-free and read-only — always exits +0. + +``` +NAME ORIGIN SOURCE TARGET DESCRIPTION +dev-to-aspice built-in dev aspice Mechanical mapping for the most common dev -> aspice transition. +dev-to-stpa project-local dev stpa Custom recipe for our STPA workflow. + path: schemas/migrations/dev-to-stpa.yaml + +Total: 2 (built-in: 1, project-local: 1) +``` + +JSON output (`--format json`) emits the same data in the +`schema-migrate-recipes` oracle shape: + +```json +{ + "oracle": "schema-migrate-recipes", + "recipes": [ + { + "name": "dev-to-aspice", + "source_preset": "dev", + "target_preset": "aspice", + "description": "...", + "origin": "built-in", + "path": null + } + ], + "warnings": [], + "total": 1 +} +``` + +`--list` is mutually exclusive with the action flags (`--apply`, +`--abort`, `--status`, `--finish`, `--continue`, `--skip`, `--edit`). + ## State machine ``` diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index f005049..94740e2 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -311,6 +311,14 @@ enum Command { #[arg(long = "strict-cited-sources")] strict_cited_sources: bool, + /// Promote `cited-source-stale` Info diagnostics to errors. A + /// `cited-source` is stale when its `last-checked` timestamp is + /// missing, unparseable, or older than 30 days. Use this in CI + /// to enforce "every cited-source must be re-checked within N + /// days" mechanically. See `rivet docs schema-cited-sources`. + #[arg(long = "strict-cited-source-stale")] + strict_cited_source_stale: bool, + /// Reserved for Phase 2 — flag is accepted but the remote /// backends (`url`, `github`, `oslc`, `reqif`, `polarion`) are /// not yet implemented. Phase 1 only verifies `kind: file`. When @@ -1014,40 +1022,53 @@ enum SchemaAction { #[command(disable_help_flag = false)] Migrate { /// Target preset (e.g., "aspice"). Source is inferred from - /// the project's current `rivet.yaml`. - target: String, + /// the project's current `rivet.yaml`. Optional when `--list` + /// is given (recipe discovery is read-only and target-free). + target: Option, /// Apply the migration; pause on first conflict (Phase 2). - #[arg(long, conflicts_with_all = ["abort", "status", "finish", "continue_", "skip", "edit"])] + #[arg(long, conflicts_with_all = ["abort", "status", "finish", "continue_", "skip", "edit", "list"])] apply: bool, /// Abort the in-flight migration and restore from snapshot. - #[arg(long, conflicts_with_all = ["apply", "status", "finish", "continue_", "skip", "edit"])] + #[arg(long, conflicts_with_all = ["apply", "status", "finish", "continue_", "skip", "edit", "list"])] abort: bool, /// Print the current migration state machine pointer. - #[arg(long, conflicts_with_all = ["apply", "abort", "finish", "continue_", "skip", "edit"])] + #[arg(long, conflicts_with_all = ["apply", "abort", "finish", "continue_", "skip", "edit", "list"])] status: bool, /// Validate and finalize a COMPLETE migration (deletes snapshot). - #[arg(long, conflicts_with_all = ["apply", "abort", "status", "continue_", "skip", "edit"])] + #[arg(long, conflicts_with_all = ["apply", "abort", "status", "continue_", "skip", "edit", "list"])] finish: bool, /// Resume after resolving the current conflict in-place /// (Phase 2). Verifies markers are gone and the file still /// parses, then advances. - #[arg(long = "continue", conflicts_with_all = ["apply", "abort", "status", "finish", "skip", "edit"])] + #[arg(long = "continue", conflicts_with_all = ["apply", "abort", "status", "finish", "skip", "edit", "list"])] continue_: bool, /// Drop the current conflicted artifact from the migration /// (restores it from the snapshot) and advance (Phase 2). - #[arg(long, conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "edit"])] + #[arg(long, conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "edit", "list"])] skip: bool, /// Re-open a previously-resolved or skipped conflict for /// re-editing (Phase 2). Takes the artifact id. - #[arg(long, value_name = "ARTIFACT_ID", conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "skip"])] + #[arg(long, value_name = "ARTIFACT_ID", conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "skip", "list"])] edit: Option, + + /// List every available migration recipe (built-in + on-disk + /// `/migrations/*.yaml`) and exit. Mutually + /// exclusive with the action flags. Pair with `--format json` + /// for machine-readable output. + #[arg(long, conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "skip", "edit"])] + list: bool, + + /// Output format for `--list`: "text" (default) or "json". + /// Ignored otherwise. + #[arg(long, default_value = "text")] + format: String, }, } @@ -1486,14 +1507,14 @@ enum CheckAction { }, /// List artifacts with `cited-source` and the current hash status - /// (match / drift / missing-hash / read-error / skipped-remote). + /// (match / drift / missing-hash / read-error / skipped-remote / stale). /// Phase 1 only handles `kind: file` — see /// `rivet docs schema-cited-sources`. Sources { /// Refresh sha256 + last-checked stamps. By default prompts /// per-artifact; pair with `--apply` for non-interactive batch - /// updates. - #[arg(long)] + /// updates. Mutually exclusive with `--strict`. + #[arg(long, conflicts_with = "strict")] update: bool, /// Skip the prompt and apply every refresh non-interactively. @@ -1501,6 +1522,15 @@ enum CheckAction { #[arg(long, requires = "update")] apply: bool, + /// Read-only audit gate: walk every cited-source, classify it, + /// and exit non-zero if anything has drifted, is missing a hash, + /// is stale (last-checked > 30 days or absent), or could not be + /// read. Does not modify any YAML — pair with `--update --apply` + /// in a separate invocation to fix. Mutually exclusive with + /// `--update`. + #[arg(long, conflicts_with = "update")] + strict: bool, + /// Output format: "text" (default) or "json". #[arg(short, long, default_value = "text")] format: String, @@ -1626,6 +1656,7 @@ fn run(cli: Cli) -> Result { binding, fail_on, strict_cited_sources, + strict_cited_source_stale, check_remote_sources, } => cmd_validate( &cli, @@ -1639,6 +1670,7 @@ fn run(cli: Cli) -> Result { binding.as_deref(), fail_on, *strict_cited_sources, + *strict_cited_source_stale, *check_remote_sources, ), Command::List { @@ -1926,8 +1958,9 @@ fn run(cli: Cli) -> Result { CheckAction::Sources { update, apply, + strict, format, - } => cmd_check_sources(&cli, *update, *apply, format), + } => cmd_check_sources(&cli, *update, *apply, *strict, format), }, #[cfg(feature = "wasm")] Command::Import { @@ -4264,6 +4297,7 @@ fn cmd_validate( binding_path: Option<&std::path::Path>, fail_on: &str, strict_cited_sources: bool, + strict_cited_source_stale: bool, check_remote_sources: bool, ) -> Result { validate_format(format, &["text", "json"])?; @@ -4441,6 +4475,7 @@ fn cmd_validate( store.iter().cloned(), &cli.project, strict_cited_sources, + strict_cited_source_stale, check_remote_sources, ); diagnostics.extend(cited_source_diags); @@ -6458,6 +6493,7 @@ fn cmd_diff( binding: None, fail_on: "error".to_string(), strict_cited_sources: false, + strict_cited_source_stale: false, check_remote_sources: false, }, }; @@ -6476,6 +6512,7 @@ fn cmd_diff( binding: None, fail_on: "error".to_string(), strict_cited_sources: false, + strict_cited_source_stale: false, check_remote_sources: false, }, }; @@ -7606,9 +7643,27 @@ fn cmd_schema(cli: &Cli, action: &SchemaAction) -> Result { continue_, skip, edit, + list, + format, } => { + validate_format(format, &["text", "json"])?; let schemas_dir = resolve_schemas_dir(cli); let project_root = cli.project.clone(); + + // --list is recipe discovery — never touches the project tree. + // Handle it before resolving source preset (which would try + // to read rivet.yaml). + if *list { + return migrate_cmd::cmd_list(&schemas_dir, format); + } + + // Every other Migrate path needs a target preset. + let target = target.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "rivet schema migrate requires a preset; pass `--list` to enumerate recipes" + ) + })?; + let config_path = cli.project.join("rivet.yaml"); let source_preset = if config_path.exists() { let config = rivet_core::load_project_config(&config_path) @@ -10115,10 +10170,21 @@ fn cmd_check_gaps_json(cli: &Cli, baseline_name: Option<&str>, format: &str) -> Ok(true) } -/// `rivet check sources [--update [--apply]]` — list artifacts with -/// `cited-source`, optionally refreshing their sha256 / last-checked -/// stamps. Phase 1 only handles `kind: file`. -fn cmd_check_sources(cli: &Cli, update: bool, apply: bool, format: &str) -> Result { +/// `rivet check sources [--update [--apply]] [--strict]` — list +/// artifacts with `cited-source`, optionally refreshing their sha256 / +/// last-checked stamps. Phase 1 only handles `kind: file`. +/// +/// `--strict` is a read-only audit gate: it never modifies any YAML and +/// the exit code includes stale entries (last-checked > 30 days or +/// missing) on top of the default drift / missing-hash / read-error / +/// shape-error set. Mutually exclusive with `--update`. +fn cmd_check_sources( + cli: &Cli, + update: bool, + apply: bool, + strict: bool, + format: &str, +) -> Result { validate_format(format, &["text", "json"])?; let ctx = ProjectContext::load(cli)?; @@ -10139,15 +10205,22 @@ fn cmd_check_sources(cli: &Cli, update: bool, apply: bool, format: &str) -> Resu } } - // The oracle's exit-code semantics: pass when no drift / read-error / + // Default exit-code semantics: pass when no drift / read-error / // missing-hash / shape-error remains. After --apply, drift and // missing-hash will have been written back, but we report on the // pre-update state so that the caller can see what happened. For // pipelines that want post-update assurance, re-run validate. - let firing = report.by_status.drift + // + // `--strict` adds stale entries to the firing set — same idea as + // `validate --strict-cited-source-stale`, but in the read-only + // `check sources` shape so audit gates don't have to touch any YAML. + let mut firing = report.by_status.drift + report.by_status.missing_hash + report.by_status.read_error + report.by_status.shape_error; + if strict { + firing += report.by_status.stale; + } Ok(firing == 0) } diff --git a/rivet-cli/src/migrate_cmd.rs b/rivet-cli/src/migrate_cmd.rs index 0bafbc7..e57a10f 100644 --- a/rivet-cli/src/migrate_cmd.rs +++ b/rivet-cli/src/migrate_cmd.rs @@ -41,7 +41,7 @@ use anyhow::{Context, Result}; use rivet_core::migrate::{ self, ActionClass, ChangeKind, MigrationLayout, MigrationManifest, MigrationRecipeFile, - MigrationState, PlannedChange, ResolutionStatus, RewriteMap, + MigrationState, PlannedChange, RecipeEntry, ResolutionStatus, RewriteMap, list_recipes, }; /// Resolve a recipe by `target_preset` against (in order): @@ -112,6 +112,82 @@ fn unix_to_ymdhm(secs: u64) -> (u32, u32, u32, u32, u32) { (y, m, d, h, mi) } +/// `rivet schema migrate --list` — enumerate every available migration +/// recipe (built-in + project-local). Read-only; never touches the +/// project tree. Always exits 0. +pub fn cmd_list(schemas_dir: &Path, format: &str) -> Result { + let (entries, warnings) = list_recipes(schemas_dir); + + if format == "json" { + // Use a wrapper struct so the shape stays stable if we ever add + // metadata at the report level (counts, version, etc.). + let payload = serde_json::json!({ + "oracle": "schema-migrate-recipes", + "recipes": entries, + "warnings": warnings, + "total": entries.len(), + }); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(true); + } + + // Text mode. + if entries.is_empty() { + println!("No migration recipes found."); + } else { + println!( + "{:<22} {:<10} {:<14} {:<14} DESCRIPTION", + "NAME", "ORIGIN", "SOURCE", "TARGET", + ); + for r in &entries { + let desc = first_line(r.description.as_deref().unwrap_or("")); + println!( + "{:<22} {:<10} {:<14} {:<14} {}", + r.name, + r.origin.as_str(), + r.source_preset, + r.target_preset, + desc, + ); + if let Some(p) = &r.path { + println!(" path: {}", p.display()); + } + } + println!(); + let by_origin = count_by_origin(&entries); + println!( + "Total: {} (built-in: {}, project-local: {})", + entries.len(), + by_origin.0, + by_origin.1, + ); + } + if !warnings.is_empty() { + eprintln!(); + for w in &warnings { + eprintln!("warning: {w}"); + } + } + Ok(true) +} + +fn first_line(s: &str) -> String { + s.lines().next().unwrap_or("").trim().to_string() +} + +fn count_by_origin(entries: &[RecipeEntry]) -> (usize, usize) { + use rivet_core::migrate::RecipeOrigin; + let mut built_in = 0; + let mut project_local = 0; + for r in entries { + match r.origin { + RecipeOrigin::BuiltIn => built_in += 1, + RecipeOrigin::ProjectLocal => project_local += 1, + } + } + (built_in, project_local) +} + /// `rivet schema migrate ` (plan). pub fn cmd_plan( project_root: &Path, diff --git a/rivet-cli/tests/cited_source_integration.rs b/rivet-cli/tests/cited_source_integration.rs index 46ff6fb..a880fb5 100644 --- a/rivet-cli/tests/cited_source_integration.rs +++ b/rivet-cli/tests/cited_source_integration.rs @@ -250,6 +250,224 @@ fn check_sources_lists_entries_in_text_mode() { assert!(stdout.contains("file"), "stdout: {stdout}"); } +/// B7 (issue #249) — `rivet check sources --strict` is a read-only +/// audit gate. On a clean fixture it exits 0; after editing the source +/// file off-disk, it exits 1 (drift). After `--update --apply` it +/// returns to exit 0. +#[test] +fn check_sources_strict_audit_gate() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let source = seed(dir); + let original = sha256_hex(b"v1\n"); + write_artifact(dir, &original); + + // Clean fixture: --strict exits 0. + let out = run_rivet(dir, &["check", "sources", "--strict"]); + assert!( + out.status.success(), + "check sources --strict should pass on clean fixture.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + // Drift the source file. + std::fs::write(&source, "v2\n").unwrap(); + + // Now --strict must exit 1 — and crucially MUST NOT modify the YAML. + let yaml_before = std::fs::read_to_string(dir.join("artifacts").join("req.yaml")).unwrap(); + let out = run_rivet(dir, &["check", "sources", "--strict"]); + let yaml_after = std::fs::read_to_string(dir.join("artifacts").join("req.yaml")).unwrap(); + assert_eq!( + yaml_before, yaml_after, + "--strict must not mutate any YAML; got diff", + ); + assert!( + !out.status.success(), + "check sources --strict should fail on drift.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + // Apply the fix in a separate invocation — same as the issue's + // recommended pattern (audit and fix are not the same command). + let upd = run_rivet(dir, &["check", "sources", "--update", "--apply"]); + assert!(upd.status.code() != Some(2)); + + // Strict gate should now pass again. + let out = run_rivet(dir, &["check", "sources", "--strict"]); + assert!( + out.status.success(), + "check sources --strict should pass after --update --apply.\nstdout: {}", + String::from_utf8_lossy(&out.stdout) + ); +} + +/// B7 (issue #249) — --strict and --update are mutually exclusive. +#[test] +fn check_sources_strict_and_update_are_mutually_exclusive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + let original = sha256_hex(b"v1\n"); + write_artifact(dir, &original); + + let out = run_rivet(dir, &["check", "sources", "--strict", "--update"]); + assert!( + !out.status.success(), + "expected clap to reject --strict + --update" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("cannot be used") || stderr.contains("conflict"), + "expected mutex error. stderr: {stderr}", + ); +} + +/// B8 (issue #249) — `--strict-cited-source-stale` promotes the +/// previously-Info `cited-source-stale` diagnostic to an Error and +/// makes `validate` exit 1. +#[test] +fn validate_strict_cited_source_stale_fails_on_old_last_checked() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + let original = sha256_hex(b"v1\n"); + // Override the artifact YAML with a stale last-checked (1970-01-01). + let yaml = format!( + r#"artifacts: + - id: REQ-001 + type: requirement + title: A test requirement + status: draft + fields: + cited-source: + uri: ./testdata/source.txt + kind: file + sha256: {original} + last-checked: 1970-01-01T00:00:00Z +"# + ); + std::fs::write(dir.join("artifacts").join("req.yaml"), yaml).unwrap(); + + // Default validate: passes (Info diagnostic only). + let out = run_rivet(dir, &["validate", "--direct"]); + assert!( + out.status.success(), + "default validate should pass on stale cited-source.\nstdout: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // --strict-cited-source-stale: exit 1. + let out = run_rivet( + dir, + &["validate", "--direct", "--strict-cited-source-stale"], + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "--strict-cited-source-stale should fail.\nstdout: {stdout}\nstderr: {stderr}" + ); + // Look for the human-readable text rather than the rule name (the + // rule name appears only in JSON output). + assert!( + stdout.contains("day(s) old") || stderr.contains("day(s) old"), + "expected stale-age diagnostic. stdout={stdout} stderr={stderr}" + ); +} + +/// B9 (issue #249) — `rivet schema migrate --list` enumerates recipes. +#[test] +fn schema_migrate_list_text() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + + let out = run_rivet(dir, &["schema", "migrate", "--list"]); + assert!( + out.status.success(), + "--list should always exit 0.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("dev-to-aspice"), + "expected built-in recipe in output. stdout: {stdout}" + ); + assert!( + stdout.contains("built-in"), + "expected origin column. stdout: {stdout}" + ); +} + +/// B9 (issue #249) — `--list --format json` emits valid JSON with the +/// expected shape. +#[test] +fn schema_migrate_list_json() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + + let out = run_rivet(dir, &["schema", "migrate", "--list", "--format", "json"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not valid JSON: {e}\n--- stdout ---\n{stdout}")); + assert_eq!(v["oracle"], "schema-migrate-recipes"); + let recipes = v["recipes"].as_array().expect("recipes array"); + assert!( + recipes.iter().any(|r| r["name"] == "dev-to-aspice"), + "expected dev-to-aspice in JSON. got: {recipes:?}" + ); +} + +/// B9 (issue #249) — project-local recipes appear with origin +/// "project-local". +#[test] +fn schema_migrate_list_includes_project_local() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + + // Write a project-local recipe. + let migrations = dir.join("schemas").join("migrations"); + std::fs::create_dir_all(&migrations).unwrap(); + std::fs::write( + migrations.join("dev-to-stpa.yaml"), + "migration:\n name: dev-to-stpa\n source: { preset: dev }\n target: { preset: stpa }\n description: 'project-local recipe'\n", + ) + .unwrap(); + + let out = run_rivet(dir, &["schema", "migrate", "--list", "--format", "json"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let recipes = v["recipes"].as_array().expect("recipes array"); + let local = recipes + .iter() + .find(|r| r["name"] == "dev-to-stpa") + .expect("dev-to-stpa in recipes"); + assert_eq!(local["origin"], "project-local"); +} + +/// B9 (issue #249) — `--list` and `--apply` are mutually exclusive. +#[test] +fn schema_migrate_list_and_apply_are_mutually_exclusive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + seed(dir); + + let out = run_rivet(dir, &["schema", "migrate", "--list", "--apply", "aspice"]); + assert!(!out.status.success(), "expected clap to reject mutex"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("cannot be used") || stderr.contains("conflict"), + "expected mutex error. stderr: {stderr}" + ); +} + #[test] fn validate_rejects_arbitrary_uri_scheme() { let tmp = tempfile::tempdir().unwrap(); diff --git a/rivet-core/src/cited_source.rs b/rivet-core/src/cited_source.rs index 1dd7447..0ad3dc0 100644 --- a/rivet-core/src/cited_source.rs +++ b/rivet-core/src/cited_source.rs @@ -284,6 +284,114 @@ pub enum CheckOutcome { SkippedRemote, } +/// Default staleness threshold (in days) for `last-checked`. +/// +/// `cited-source-stale` Info diagnostics fire when `last-checked` is +/// missing or older than this many days. Phase 1 ships a single global +/// default; per-schema overrides are deferred to a follow-up feature. +pub const STALE_DAYS_DEFAULT: i64 = 30; + +/// Parse an ISO-8601 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) into epoch +/// seconds. Returns `None` if the string is malformed. +/// +/// Hand-rolled to avoid pulling chrono into the core. Accepts the +/// canonical `Z` form rivet emits via [`current_iso8601_utc`]; tolerates +/// fractional seconds like `2026-04-27T12:00:00.123Z` by truncating. +pub fn parse_iso8601_utc(s: &str) -> Option { + // Strip trailing Z (required) and any fractional seconds. + let s = s.strip_suffix('Z')?; + // Strip fractional seconds (e.g. `.123`). + let s = match s.find('.') { + Some(i) => &s[..i], + None => s, + }; + // Expect "YYYY-MM-DDTHH:MM:SS" + let (date, time) = s.split_once('T')?; + + let (year_s, rest) = date.split_once('-')?; + let (month_s, day_s) = rest.split_once('-')?; + let year: i64 = year_s.parse().ok()?; + let month: i64 = month_s.parse().ok()?; + let day: i64 = day_s.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + let (hour_s, rest) = time.split_once(':')?; + let (minute_s, second_s) = rest.split_once(':')?; + let hour: i64 = hour_s.parse().ok()?; + let minute: i64 = minute_s.parse().ok()?; + let second: i64 = second_s.parse().ok()?; + if !(0..24).contains(&hour) || !(0..60).contains(&minute) || !(0..=60).contains(&second) { + return None; + } + + // Howard Hinnant's "days_from_civil" — inverse of the civil_from_days + // used to format these timestamps elsewhere in the codebase. + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; // [0, 399] + let m = month; + let d = day; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + let days = era * 146_097 + doe - 719_468; + + Some(days * 86_400 + hour * 3600 + minute * 60 + second) +} + +/// Best-effort current epoch seconds (UTC). Returns 0 on clock errors, +/// which are practically impossible. +fn now_epoch_seconds() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Staleness verdict for a `last-checked` timestamp. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StaleStatus { + /// `last-checked` is present and within the freshness window. + Fresh, + /// `last-checked` is missing. + Missing, + /// `last-checked` is present but older than `threshold_days` (carries + /// the computed age in days). + Old { age_days: i64 }, + /// `last-checked` is present but malformed; treated as stale so the + /// audit doesn't silently pass. + Unparseable, +} + +/// Compute the staleness verdict for an optional `last-checked` value +/// against a threshold (in days). `now_epoch` is exposed for testing. +pub fn classify_staleness( + last_checked: Option<&str>, + threshold_days: i64, + now_epoch: i64, +) -> StaleStatus { + let Some(s) = last_checked else { + return StaleStatus::Missing; + }; + let Some(checked_epoch) = parse_iso8601_utc(s) else { + return StaleStatus::Unparseable; + }; + let age_seconds = now_epoch - checked_epoch; + let age_days = age_seconds.div_euclid(86_400); + if age_days > threshold_days { + StaleStatus::Old { age_days } + } else { + StaleStatus::Fresh + } +} + +/// Public wrapper that uses the system clock. +pub fn classify_staleness_now(last_checked: Option<&str>, threshold_days: i64) -> StaleStatus { + classify_staleness(last_checked, threshold_days, now_epoch_seconds()) +} + /// Check one `cited-source` field for drift. /// /// `project_root` is the directory used to resolve relative `kind: file` @@ -323,11 +431,14 @@ pub fn check_cited_source( /// `project_root` is used to resolve relative `kind: file` URIs. The /// `strict` flag promotes drift / missing-hash diagnostics from /// `Severity::Warning` to `Severity::Error` (the -/// `--strict-cited-sources` CLI flag). +/// `--strict-cited-sources` CLI flag). `strict_stale` similarly +/// promotes `cited-source-stale` from `Severity::Info` to +/// `Severity::Error` (the `--strict-cited-source-stale` flag). pub fn validate_cited_sources( artifacts: impl IntoIterator, project_root: &Path, strict: bool, + strict_stale: bool, check_remote: bool, ) -> Vec { let mut diagnostics = Vec::new(); @@ -336,6 +447,12 @@ pub fn validate_cited_sources( } else { Severity::Warning }; + let stale_severity = if strict_stale { + Severity::Error + } else { + Severity::Info + }; + let now = now_epoch_seconds(); for artifact in artifacts { let Some(raw) = artifact.fields.get("cited-source") else { @@ -411,16 +528,45 @@ pub fn validate_cited_sources( } } - if parsed.last_checked.is_none() && parsed.kind.is_local() { - diagnostics.push(Diagnostic::new( - Severity::Info, - Some(artifact.id.clone()), - "cited-source-stale", - format!( - "cited-source '{}' has no 'last-checked' timestamp; consider running `rivet check sources --update`", - parsed.uri, - ), - )); + if parsed.kind.is_local() { + let stale = classify_staleness(parsed.last_checked.as_deref(), STALE_DAYS_DEFAULT, now); + match stale { + StaleStatus::Fresh => {} + StaleStatus::Missing => { + diagnostics.push(Diagnostic::new( + stale_severity, + Some(artifact.id.clone()), + "cited-source-stale", + format!( + "cited-source '{}' has no 'last-checked' timestamp; consider running `rivet check sources --update`", + parsed.uri, + ), + )); + } + StaleStatus::Old { age_days } => { + diagnostics.push(Diagnostic::new( + stale_severity, + Some(artifact.id.clone()), + "cited-source-stale", + format!( + "cited-source '{}' last-checked is {age_days} day(s) old (threshold: {STALE_DAYS_DEFAULT}); re-verify with `rivet check sources --update`", + parsed.uri, + ), + )); + } + StaleStatus::Unparseable => { + diagnostics.push(Diagnostic::new( + stale_severity, + Some(artifact.id.clone()), + "cited-source-stale", + format!( + "cited-source '{}' has an unparseable 'last-checked' timestamp ({}); expected ISO-8601 UTC like 2026-04-27T12:00:00Z", + parsed.uri, + parsed.last_checked.as_deref().unwrap_or(""), + ), + )); + } + } } } @@ -826,7 +972,7 @@ last-checked: 2026-04-28T14:30:00Z .fields .insert("cited-source".into(), serde_yaml::Value::Mapping(cs_map)); - let diags = validate_cited_sources(vec![artifact], dir.path(), false, false); + let diags = validate_cited_sources(vec![artifact], dir.path(), false, false, false); assert!(diags.iter().any(|d| d.rule == "cited-source-drift")); } @@ -858,7 +1004,7 @@ last-checked: 2026-04-28T14:30:00Z .fields .insert("cited-source".into(), serde_yaml::Value::Mapping(cs_map)); - let diags = validate_cited_sources(vec![artifact], dir.path(), true, false); + let diags = validate_cited_sources(vec![artifact], dir.path(), true, false, false); let drift = diags .iter() .find(|d| d.rule == "cited-source-drift") @@ -880,7 +1026,7 @@ last-checked: 2026-04-28T14:30:00Z provenance: None, source_file: None, }; - let diags = validate_cited_sources(vec![artifact], Path::new("."), false, false); + let diags = validate_cited_sources(vec![artifact], Path::new("."), false, false, false); assert!(diags.is_empty()); } @@ -973,6 +1119,181 @@ artifacts: assert!(updated.contains("last-checked: 2026-04-27T12:00:00Z")); } + #[test] + fn parse_iso8601_known_round_trip() { + // 1970-01-01T00:00:00Z is epoch 0. + assert_eq!(parse_iso8601_utc("1970-01-01T00:00:00Z"), Some(0)); + // 2026-04-27T00:00:00Z — sanity: positive seconds. + assert!(parse_iso8601_utc("2026-04-27T00:00:00Z").unwrap() > 0); + // Fractional seconds tolerated. + assert_eq!(parse_iso8601_utc("1970-01-01T00:00:01.123Z"), Some(1)); + // Non-UTC / no Z is rejected. + assert_eq!(parse_iso8601_utc("2026-04-27T00:00:00"), None); + // Garbage rejected. + assert_eq!(parse_iso8601_utc("not-a-date"), None); + } + + #[test] + fn classify_staleness_fresh_missing_old() { + // now = 2026-04-27T00:00:00Z (epoch 1777_564_800) + let now = parse_iso8601_utc("2026-04-27T00:00:00Z").unwrap(); + // 10 days ago — fresh under 30d threshold. + let recent = parse_iso8601_utc("2026-04-17T00:00:00Z").unwrap(); + assert_eq!(now - recent, 10 * 86_400); + let s = "2026-04-17T00:00:00Z"; + assert_eq!(classify_staleness(Some(s), 30, now), StaleStatus::Fresh); + + // 60 days ago — stale under 30d threshold. + let s = "2026-02-26T00:00:00Z"; + match classify_staleness(Some(s), 30, now) { + StaleStatus::Old { age_days } => assert!(age_days > 30), + other => panic!("expected Old, got {other:?}"), + } + + // Missing. + assert_eq!(classify_staleness(None, 30, now), StaleStatus::Missing); + + // Unparseable. + assert_eq!( + classify_staleness(Some("2026-13-99"), 30, now), + StaleStatus::Unparseable + ); + } + + #[test] + fn validate_cited_sources_stale_default_is_info() { + // A cited-source with no last-checked yields a stale Info diag. + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("doc.md"); + std::fs::write(&p, "v1").unwrap(); + let h = sha256_hex(b"v1"); + + let mut artifact = Artifact { + id: "REQ-1".into(), + artifact_type: "requirement".into(), + title: "t".into(), + description: None, + status: None, + tags: vec![], + links: vec![], + fields: BTreeMap::new(), + provenance: None, + source_file: None, + }; + let mut cs_map = serde_yaml::Mapping::new(); + cs_map.insert("uri".into(), "doc.md".into()); + cs_map.insert("kind".into(), "file".into()); + cs_map.insert("sha256".into(), h.into()); + // Note: no last-checked + artifact + .fields + .insert("cited-source".into(), serde_yaml::Value::Mapping(cs_map)); + + let diags = validate_cited_sources(vec![artifact], dir.path(), false, false, false); + let stale = diags + .iter() + .find(|d| d.rule == "cited-source-stale") + .expect("stale diag"); + assert_eq!(stale.severity, Severity::Info); + } + + #[test] + fn validate_cited_sources_strict_stale_promotes_to_error() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("doc.md"); + std::fs::write(&p, "v1").unwrap(); + let h = sha256_hex(b"v1"); + + let mut artifact = Artifact { + id: "REQ-1".into(), + artifact_type: "requirement".into(), + title: "t".into(), + description: None, + status: None, + tags: vec![], + links: vec![], + fields: BTreeMap::new(), + provenance: None, + source_file: None, + }; + let mut cs_map = serde_yaml::Mapping::new(); + cs_map.insert("uri".into(), "doc.md".into()); + cs_map.insert("kind".into(), "file".into()); + cs_map.insert("sha256".into(), h.into()); + // Old last-checked: 1970-01-01. + cs_map.insert("last-checked".into(), "1970-01-01T00:00:00Z".into()); + artifact + .fields + .insert("cited-source".into(), serde_yaml::Value::Mapping(cs_map)); + + let diags = validate_cited_sources(vec![artifact], dir.path(), false, true, false); + let stale = diags + .iter() + .find(|d| d.rule == "cited-source-stale") + .expect("stale diag"); + assert_eq!(stale.severity, Severity::Error); + assert!(stale.message.contains("day(s) old")); + } + + #[test] + fn validate_cited_sources_fresh_last_checked_no_stale_diag() { + // A cited-source with a fresh last-checked produces no stale diag. + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("doc.md"); + std::fs::write(&p, "v1").unwrap(); + let h = sha256_hex(b"v1"); + + // Synthesize a "now-ish" timestamp by formatting the current epoch. + let fresh = { + let secs = now_epoch_seconds(); + // Keep it simple: subtract 1 hour. The format is permitted as long as it parses. + // Use the helper from the sources module path indirectly: format inline. + let days = secs.div_euclid(86_400); + let secs_of_day = secs.rem_euclid(86_400); + let h = secs_of_day / 3600; + let m = (secs_of_day % 3600) / 60; + let s = secs_of_day % 60; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = (yoe as i64) + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m_civ = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m_civ <= 2 { y + 1 } else { y }; + format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m_civ, d, h, m, s) + }; + + let mut artifact = Artifact { + id: "REQ-1".into(), + artifact_type: "requirement".into(), + title: "t".into(), + description: None, + status: None, + tags: vec![], + links: vec![], + fields: BTreeMap::new(), + provenance: None, + source_file: None, + }; + let mut cs_map = serde_yaml::Mapping::new(); + cs_map.insert("uri".into(), "doc.md".into()); + cs_map.insert("kind".into(), "file".into()); + cs_map.insert("sha256".into(), h.into()); + cs_map.insert("last-checked".into(), fresh.into()); + artifact + .fields + .insert("cited-source".into(), serde_yaml::Value::Mapping(cs_map)); + + let diags = validate_cited_sources(vec![artifact], dir.path(), false, false, false); + assert!( + diags.iter().all(|d| d.rule != "cited-source-stale"), + "expected no stale diag, got: {diags:?}" + ); + } + #[test] fn update_cited_source_idempotent() { let dir = tempfile::tempdir().unwrap(); diff --git a/rivet-core/src/migrate.rs b/rivet-core/src/migrate.rs index 0b18da9..82c428e 100644 --- a/rivet-core/src/migrate.rs +++ b/rivet-core/src/migrate.rs @@ -161,6 +161,119 @@ impl MigrationRecipeFile { } } +// ── Recipe registry (discovery) ───────────────────────────────────────── + +/// Where a registered migration recipe came from. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum RecipeOrigin { + /// Compiled into the binary via `include_str!`. + BuiltIn, + /// Loaded from `/migrations/*.yaml`. + ProjectLocal, +} + +impl RecipeOrigin { + /// Human-readable label. + pub fn as_str(self) -> &'static str { + match self { + RecipeOrigin::BuiltIn => "built-in", + RecipeOrigin::ProjectLocal => "project-local", + } + } +} + +/// A recipe entry surfaced by [`list_recipes`]. +/// +/// Designed for `rivet schema migrate --list` and any future programmatic +/// consumer (dashboard, MCP). Carries only metadata — the full +/// [`MigrationRecipe`] is loaded lazily via [`MigrationRecipeFile`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RecipeEntry { + pub name: String, + pub source_preset: String, + pub target_preset: String, + pub description: Option, + pub origin: RecipeOrigin, + /// Absolute path on disk for project-local recipes; None for built-ins. + pub path: Option, +} + +/// Enumerate every available migration recipe — built-in and +/// project-local. Project-local recipes (`/migrations/*.yaml`) +/// shadow built-ins of the same name (the project-local copy is the one +/// returned). +/// +/// Errors loading individual on-disk recipes are returned as `Err` only +/// when the migrations directory itself is unreadable; per-file parse +/// failures are surfaced to the caller as warnings via the second tuple +/// element of the result. +pub fn list_recipes(schemas_dir: &Path) -> (Vec, Vec) { + use crate::embedded::MIGRATION_RECIPES; + + let mut warnings: Vec = Vec::new(); + let mut by_name: BTreeMap = BTreeMap::new(); + + // Built-in recipes first. + for (name, content) in MIGRATION_RECIPES.iter() { + match MigrationRecipeFile::parse(content) { + Ok(file) => { + let r = file.migration; + by_name.insert( + (*name).to_string(), + RecipeEntry { + name: r.name.clone(), + source_preset: r.source.preset.clone(), + target_preset: r.target.preset.clone(), + description: r.description.clone(), + origin: RecipeOrigin::BuiltIn, + path: None, + }, + ); + } + Err(e) => warnings.push(format!("built-in recipe '{name}': {e}")), + } + } + + // Then walk schemas/migrations/*.yaml. A project-local recipe with + // the same name as a built-in shadows the built-in. + let dir = schemas_dir.join("migrations"); + if dir.exists() { + match std::fs::read_dir(&dir) { + Ok(rd) => { + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("yaml") { + continue; + } + match MigrationRecipeFile::load(&path) { + Ok(file) => { + let r = file.migration; + by_name.insert( + r.name.clone(), + RecipeEntry { + name: r.name.clone(), + source_preset: r.source.preset.clone(), + target_preset: r.target.preset.clone(), + description: r.description.clone(), + origin: RecipeOrigin::ProjectLocal, + path: Some(path.clone()), + }, + ); + } + Err(e) => warnings.push(format!("recipe {}: {e}", path.display())), + } + } + } + Err(e) => warnings.push(format!("reading {}: {e}", dir.display())), + } + } + + let mut entries: Vec = by_name.into_values().collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + (entries, warnings) +} + // ── Diff engine ───────────────────────────────────────────────────────── /// Action class for a single per-artifact change. Mirrors the rebase @@ -1653,4 +1766,66 @@ mod tests { assert!(out.contains("type: sw-req")); assert!(out.contains("priority: 5"), "conflict left for marker pass"); } + + #[test] + fn list_recipes_includes_built_in_dev_to_aspice() { + let dir = tempfile::tempdir().unwrap(); + let (entries, warnings) = list_recipes(dir.path()); + assert!( + warnings.is_empty(), + "expected no warnings, got: {warnings:?}" + ); + let dev = entries + .iter() + .find(|r| r.name == "dev-to-aspice") + .expect("dev-to-aspice in registry"); + assert_eq!(dev.source_preset, "dev"); + assert_eq!(dev.target_preset, "aspice"); + assert_eq!(dev.origin, RecipeOrigin::BuiltIn); + assert!(dev.path.is_none()); + } + + #[test] + fn list_recipes_picks_up_project_local_yaml() { + let dir = tempfile::tempdir().unwrap(); + let migrations = dir.path().join("migrations"); + std::fs::create_dir_all(&migrations).unwrap(); + std::fs::write( + migrations.join("dev-to-stpa.yaml"), + "migration:\n name: dev-to-stpa\n source: { preset: dev }\n target: { preset: stpa }\n description: 'local recipe'\n", + ) + .unwrap(); + + let (entries, warnings) = list_recipes(dir.path()); + assert!(warnings.is_empty(), "warnings: {warnings:?}"); + let local = entries + .iter() + .find(|r| r.name == "dev-to-stpa") + .expect("project-local recipe"); + assert_eq!(local.origin, RecipeOrigin::ProjectLocal); + assert!(local.path.is_some()); + // built-in still present + assert!(entries.iter().any(|r| r.name == "dev-to-aspice")); + } + + #[test] + fn list_recipes_project_local_shadows_built_in() { + let dir = tempfile::tempdir().unwrap(); + let migrations = dir.path().join("migrations"); + std::fs::create_dir_all(&migrations).unwrap(); + // Same name as the built-in `dev-to-aspice`, different description. + std::fs::write( + migrations.join("dev-to-aspice.yaml"), + "migration:\n name: dev-to-aspice\n source: { preset: dev }\n target: { preset: aspice }\n description: 'project override'\n", + ) + .unwrap(); + + let (entries, _warnings) = list_recipes(dir.path()); + let r = entries + .iter() + .find(|r| r.name == "dev-to-aspice") + .expect("dev-to-aspice"); + assert_eq!(r.origin, RecipeOrigin::ProjectLocal); + assert_eq!(r.description.as_deref(), Some("project override")); + } }