From 6d3894dd8848bc0bbcce194f7b7a499e0cb725a3 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Mon, 10 Aug 2026 00:23:06 -0700 Subject: [PATCH] feat(client): display and gate on provisional evidence tier (P6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionEntry gains optional evidence_tier/deferral_reason, parsed from the registry index (absence means proven, per the two-tier model). - numan info: shows "evidence: provisional — " under a provisional version (falls back to "reason not recorded" if deferral_reason is empty/missing). - numan search: appends a " [provisional]" marker to the displayed version's row. - numan install: prints a one-time notice ("has not been lifecycle-tested... passed integrity checks") right after a provisional package installs successfully (not on the already-installed no-op path). Ref: docs/plans/2026-08-09-intake-process-reform.md (P6) --- src/cmd/info.rs | 37 ++++++++++++++++++++ src/cmd/search.rs | 43 +++++++++++++++++++++-- src/cmd/try_cmd.rs | 2 ++ src/core/package.rs | 56 ++++++++++++++++++++++++++++++ src/core/registry.rs | 2 ++ src/core/resolve.rs | 6 ++++ src/install/transaction.rs | 11 ++++++ tests/install_test.rs | 8 +++++ tests/support/active_update/mod.rs | 2 ++ 9 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/cmd/info.rs b/src/cmd/info.rs index a6d27c8c..6d8570ce 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -79,6 +79,14 @@ pub fn format_info(pkg: &Package, platform: &Platform, nu: Option<&NuVersion>) - )); } + if ver.is_provisional() { + let reason = ver + .deferral_reason + .as_deref() + .unwrap_or("reason not recorded"); + out.push_str(&format!(" evidence: provisional — {reason}\n")); + } + if let Some(ref source) = ver.source { out.push_str(&format!(" source git: {}\n", source.git)); out.push_str(&format!(" source rev: {}\n", source.rev)); @@ -161,6 +169,8 @@ mod tests { }), dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], } } @@ -195,6 +205,33 @@ mod tests { assert!(out.contains("cargo_name: nu_plugin_highlight"), "{out}"); } + #[test] + fn format_info_notes_provisional_with_reason() { + let mut pkg = sample_plugin(false); + pkg.versions[0].evidence_tier = Some("provisional".to_string()); + pkg.versions[0].deferral_reason = Some("requires cloud credentials".to_string()); + let out = format_info(&pkg, &linux_platform(), None); + assert!( + out.contains("evidence: provisional — requires cloud credentials"), + "{out}" + ); + } + + #[test] + fn format_info_notes_provisional_without_reason() { + let mut pkg = sample_plugin(false); + pkg.versions[0].evidence_tier = Some("provisional".to_string()); + let out = format_info(&pkg, &linux_platform(), None); + assert!(out.contains("reason not recorded"), "{out}"); + } + + #[test] + fn format_info_omits_evidence_line_when_proven() { + let pkg = sample_plugin(false); + let out = format_info(&pkg, &linux_platform(), None); + assert!(!out.contains("evidence:"), "{out}"); + } + #[test] fn format_info_omits_source_lines_when_absent() { let pkg = sample_plugin(false); diff --git a/src/cmd/search.rs b/src/cmd/search.rs index ac3d9bb4..eb870ada 100644 --- a/src/cmd/search.rs +++ b/src/cmd/search.rs @@ -1,5 +1,5 @@ use crate::core::nu_version::NuVersion; -use crate::core::package::PackageType; +use crate::core::package::{PackageType, VersionEntry}; use crate::core::platform::Platform; use crate::core::registry::RegistryManager; use crate::core::resolve::{Incompatibility, Resolver}; @@ -101,10 +101,18 @@ pub fn execute(args: &SearchArgs, root: &Path) -> Result<()> { } }; + let provisional_marker = provisional_marker(display_entry); + println!( - " {}/{} v{} [{}]{} + " {}/{} v{} [{}]{}{} {}", - pkg.id.owner, pkg.id.name, version_label, pkg.package_type, status, pkg.description + pkg.id.owner, + pkg.id.name, + version_label, + pkg.package_type, + status, + provisional_marker, + pkg.description ); } @@ -140,6 +148,15 @@ fn format_search_header(nu: Option<&NuVersion>, triple: &str) -> String { } } +/// Row suffix marking a provisional (not lifecycle-tested) displayed version. +fn provisional_marker(entry: Option<&VersionEntry>) -> &'static str { + if entry.map(|v| v.is_provisional()).unwrap_or(false) { + " [provisional]" + } else { + "" + } +} + /// Row status suffix (leading space + brackets), or empty. /// /// Plugins get a hard evaluated verdict. Modules use not-ABI-locked wording. @@ -251,6 +268,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], } } @@ -336,6 +355,24 @@ mod tests { assert!(status.is_empty()); } + #[test] + fn provisional_marker_shown_for_provisional_entry() { + let mut pkg = sample_pkg_typed(PackageType::Plugin, "*", vec![]); + pkg.versions[0].evidence_tier = Some("provisional".to_string()); + assert_eq!(provisional_marker(Some(&pkg.versions[0])), " [provisional]"); + } + + #[test] + fn provisional_marker_empty_for_proven_entry() { + let pkg = sample_pkg_typed(PackageType::Plugin, "*", vec![]); + assert_eq!(provisional_marker(Some(&pkg.versions[0])), ""); + } + + #[test] + fn provisional_marker_empty_for_none() { + assert_eq!(provisional_marker(None), ""); + } + #[test] fn hidden_footer_points_at_info_not_sync() { let footer = format_hidden_footer( diff --git a/src/cmd/try_cmd.rs b/src/cmd/try_cmd.rs index 18d60193..50371ebc 100644 --- a/src/cmd/try_cmd.rs +++ b/src/cmd/try_cmd.rs @@ -468,6 +468,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], } } diff --git a/src/core/package.rs b/src/core/package.rs index baa44925..8de2c3d9 100644 --- a/src/core/package.rs +++ b/src/core/package.rs @@ -112,6 +112,22 @@ pub struct VersionEntry { /// `None` for plugins, scripts, and completions. #[serde(default)] pub activation: Option, + /// Evidence tier for this version. `None` means "proven" (legacy + /// entries and any entry with full lifecycle evidence). + #[serde(default)] + pub evidence_tier: Option, + /// Required alongside `evidence_tier: "provisional"`: why + /// lifecycle-prove was deferred for this version. + #[serde(default)] + pub deferral_reason: Option, +} + +impl VersionEntry { + /// Whether this version is provisional (structural validation only, no + /// full lifecycle evidence). Absent `evidence_tier` means proven. + pub fn is_provisional(&self) -> bool { + self.evidence_tier.as_deref() == Some("provisional") + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -269,6 +285,46 @@ mod tests { assert!(entry.source.is_none()); } + #[test] + fn version_entry_absent_evidence_tier_is_not_provisional() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(!entry.is_provisional()); + } + + #[test] + fn version_entry_provisional_evidence_tier() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "provisional", + "deferral_reason": "requires cloud credentials", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(entry.is_provisional()); + assert_eq!( + entry.deferral_reason.as_deref(), + Some("requires cloud credentials") + ); + } + + #[test] + fn version_entry_proven_evidence_tier_is_not_provisional() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "proven", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(!entry.is_provisional()); + } + #[test] fn parse_version_entry_with_source() { let json = r#"{ diff --git a/src/core/registry.rs b/src/core/registry.rs index a80ba0ab..c2a39849 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -406,6 +406,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], }], } diff --git a/src/core/resolve.rs b/src/core/resolve.rs index 96d542c5..906c109f 100644 --- a/src/core/resolve.rs +++ b/src/core/resolve.rs @@ -726,6 +726,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }, VersionEntry { version: semver::Version::new(1, 0, 0), @@ -743,6 +745,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }, ], } @@ -1055,6 +1059,8 @@ mod tests { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], } } diff --git a/src/install/transaction.rs b/src/install/transaction.rs index fd489de0..d774e5d0 100644 --- a/src/install/transaction.rs +++ b/src/install/transaction.rs @@ -408,6 +408,17 @@ pub fn install_package( install_dir.display() ); + if resolved.is_provisional() { + let reason = resolved + .deferral_reason + .as_deref() + .unwrap_or("reason not recorded"); + println!( + "{} This package has not been lifecycle-tested. It passed integrity checks. ({reason})", + console::style("!").yellow() + ); + } + Ok(InstallResult { installed: true, package: lock_key, diff --git a/tests/install_test.rs b/tests/install_test.rs index 07cb9680..f242d337 100644 --- a/tests/install_test.rs +++ b/tests/install_test.rs @@ -137,6 +137,8 @@ fn integration_full_install_from_signed_registry() { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -381,6 +383,8 @@ fn integration_resolve_exact_rejects_incompatible() { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -463,6 +467,8 @@ fn integration_snapshot_before_install() { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -533,6 +539,8 @@ fn integration_snapshot_before_install() { source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, }], }; diff --git a/tests/support/active_update/mod.rs b/tests/support/active_update/mod.rs index 1eed98d9..13c32e68 100644 --- a/tests/support/active_update/mod.rs +++ b/tests/support/active_update/mod.rs @@ -506,6 +506,8 @@ fn write_dual_version_registry( source: None, dependencies: BTreeMap::new(), activation: None, + evidence_tier: None, + deferral_reason: None, } };