Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/cmd/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ pub fn format_info(pkg: &Package, platform: &Platform, nu: Option<&NuVersion>) -
out.push_str(" note: built from a commit snapshot, not a tagged release\n");
}

if ver.is_provisional() {
let reason = ver.deferral_reason_display();
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));
Expand Down Expand Up @@ -166,6 +171,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}
}
Expand Down Expand Up @@ -226,6 +233,42 @@ mod tests {
assert!(!out.contains("commit snapshot"), "{out}");
}

#[test]
fn format_info_notes_provisional_with_reason() {
let mut pkg = sample_plugin(false);
pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional);
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(crate::core::package::EvidenceTier::Provisional);
let out = format_info(&pkg, &linux_platform(), None);
assert!(out.contains("reason not recorded"), "{out}");
}

#[test]
fn format_info_notes_provisional_with_empty_reason_falls_back() {
let mut pkg = sample_plugin(false);
pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional);
pkg.versions[0].deferral_reason = Some(" ".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);
Expand Down
42 changes: 39 additions & 3 deletions src/cmd/search.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -140,6 +148,14 @@ fn format_search_header(nu: Option<&NuVersion>, triple: &str) -> String {
}
}

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.
Expand Down Expand Up @@ -252,6 +268,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}
}
Expand Down Expand Up @@ -337,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(crate::core::package::EvidenceTier::Provisional);
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(
Expand Down
2 changes: 2 additions & 0 deletions src/cmd/try_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}
}
Expand Down
126 changes: 126 additions & 0 deletions src/core/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,50 @@ pub struct VersionEntry {
/// built from a pinned commit with no upstream tag.
#[serde(default)]
pub provenance: Option<String>,
/// Evidence tier for this version. `None` means "proven" (legacy
/// entries and any entry with full lifecycle evidence).
#[serde(default)]
pub evidence_tier: Option<EvidenceTier>,
/// Required alongside `evidence_tier: "provisional"`: why
/// lifecycle-prove was deferred for this version.
#[serde(default)]
pub deferral_reason: Option<String>,
}

/// Closed set of evidence tiers; unrecognized values fail deserialization
/// instead of silently being treated as proven.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceTier {
Proven,
Provisional,
}

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 == Some(EvidenceTier::Provisional)
}

/// Sanitized, non-empty deferral reason for display, or the fallback
/// text when the reason is missing, blank, or contains control
/// characters that would corrupt terminal output.
pub fn deferral_reason_display(&self) -> String {
let cleaned: String = self
.deferral_reason
.as_deref()
.unwrap_or("")
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"reason not recorded".to_string()
} else {
trimmed.to_string()
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -273,6 +317,17 @@ 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 parse_version_entry_defaults_provenance_to_none() {
let json = r#"{
Expand All @@ -284,6 +339,77 @@ mod tests {
assert!(entry.provenance.is_none());
}

#[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 deferral_reason_display_falls_back_when_missing() {
let json = r#"{
"version": "0.25.2",
"nu_version": ">=0.113.0 <0.114.0",
"evidence_tier": "provisional",
"artifact": { "kind": "binary", "targets": {} }
}"#;
let entry: VersionEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.deferral_reason_display(), "reason not recorded");
}

#[test]
fn deferral_reason_display_falls_back_when_blank() {
let mut entry: VersionEntry =
serde_json::from_str(r#"{"version":"0.25.2","nu_version":">=0.113.0 <0.114.0","artifact":{"kind":"binary","targets":{}}}"#).unwrap();
entry.deferral_reason = Some(" ".to_string());
assert_eq!(entry.deferral_reason_display(), "reason not recorded");
}

#[test]
fn deferral_reason_display_strips_control_characters() {
let mut entry: VersionEntry =
serde_json::from_str(r#"{"version":"0.25.2","nu_version":">=0.113.0 <0.114.0","artifact":{"kind":"binary","targets":{}}}"#).unwrap();
entry.deferral_reason = Some("line one\nfake success line".to_string());
assert_eq!(
entry.deferral_reason_display(),
"line one fake success line"
);
}

#[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 version_entry_rejects_unknown_evidence_tier() {
let json = r#"{
"version": "0.25.2",
"nu_version": ">=0.113.0 <0.114.0",
"evidence_tier": "provisonal",
"artifact": { "kind": "binary", "targets": {} }
}"#;
assert!(serde_json::from_str::<VersionEntry>(json).is_err());
}

#[test]
fn parse_version_entry_with_commit_snapshot_provenance() {
let json = r#"{
Expand Down
2 changes: 2 additions & 0 deletions src/core/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}],
}
Expand Down
6 changes: 6 additions & 0 deletions src/core/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
},
VersionEntry {
version: semver::Version::new(1, 0, 0),
Expand All @@ -745,6 +747,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
},
],
}
Expand Down Expand Up @@ -1058,6 +1062,8 @@ mod tests {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/install/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,14 @@ pub fn install_package(
install_dir.display()
);

if resolved.is_provisional() {
let reason = resolved.deferral_reason_display();
println!(
"{} This package has not been lifecycle-tested. It passed integrity checks. ({reason})",
console::style("!").yellow()
);
Comment thread
tonythethompson marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

Ok(InstallResult {
installed: true,
package: lock_key,
Expand Down
8 changes: 8 additions & 0 deletions tests/install_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ fn integration_full_install_from_signed_registry() {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
Comment thread
tonythethompson marked this conversation as resolved.
}],
};

Expand Down Expand Up @@ -383,6 +385,8 @@ fn integration_resolve_exact_rejects_incompatible() {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
};

Expand Down Expand Up @@ -466,6 +470,8 @@ fn integration_snapshot_before_install() {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
};

Expand Down Expand Up @@ -537,6 +543,8 @@ fn integration_snapshot_before_install() {
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}],
};

Expand Down
2 changes: 2 additions & 0 deletions tests/support/active_update/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ fn write_dual_version_registry(
dependencies: BTreeMap::new(),
activation: None,
provenance: None,
evidence_tier: None,
deferral_reason: None,
}
};

Expand Down
Loading