Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-release-core/src/edition.rs

//! `release gate edition` — Rust **2024** / cargo-standards compliance.
//!
//! "Is this crate OK and following the current Rust/Cargo standard?" answered at
//! release time. Two layers:
//!
//! * **static** (cheap, via `cargo_metadata`): every PUBLISHABLE package declares
//!   `edition = "2024"` and an MSRV `rust-version >= 1.85` (edition 2024's floor),
//!   and the workspace sets `resolver = "3"` (the 2024 default).
//! * **dynamic** (via a `cargo` subprocess): force the `rust-2024-compatibility`
//!   lint group to warn and `cargo check --message-format=json`; any diagnostic
//!   that renders a "Rust 2024 / 2024 edition" note is a real incompatibility.
//!
//! There is NO stable cargo library API — the supported integration is exactly
//! this: `cargo_metadata` for structure + the `cargo` CLI with JSON messages for
//! lints. The pure analyzers below are unit-tested; the thin runner shells out.

use std::path::Path;
use std::process::Command;

use anyhow::{Context, Result};
use serde::Serialize;

/// Edition 2024 requires Rust 1.85+.
const EDITION_MSRV: (u64, u64) = (1, 85);

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct EditionFinding {
    /// Package name, or `[workspace]` for the resolver finding.
    pub package: String,
    pub issue: String,
}

/// One package's edition-relevant facts, lifted out of `cargo metadata` so the
/// classifier stays pure.
#[derive(Debug, Clone)]
pub struct PkgEdition {
    pub name: String,
    pub edition: String,
    /// Declared `rust-version` (MSRV), if any, as a version string.
    pub rust_version: Option<String>,
    /// `false` only when `publish = false` (those crates never ship, so they don't
    /// gate a release).
    pub publishable: bool,
}

/// `Default` = the historical CLEAN report: every vec empty and `lint_pass_ran = false`,
/// so `EditionReport::default().is_clean()` is `true`. It exists so a FUTURE field
/// addition can't break a consumer's literal construction — fallback/fixture sites (here
/// and in downstream crates like nornir) spread `..Default::default()` and pick up the new
/// field for free. The `default_report_is_clean` test locks this semantics down.
#[derive(Debug, Clone, Default, Serialize)]
pub struct EditionReport {
    pub static_findings: Vec<EditionFinding>,
    /// Rendered `rust-2024-compatibility` diagnostics from `cargo check`.
    pub lints: Vec<String>,
    /// `true` when the dynamic lint pass actually ran (cargo invoked + parsed).
    pub lint_pass_ran: bool,
    /// P4 — ADVISORY notes: a resolved dependency whose own `rust-version` (MSRV) EXCEEDS
    /// the workspace's declared MSRV floor, so it SILENTLY raises our effective MSRV above
    /// what we promise downstream (the sneaky vector `edition.rs`'s floor check misses).
    /// Informational (never reds `is_clean`) — surfacing, not blocking, per the doc.
    pub msrv_advisories: Vec<String>,
    /// P4 — errors from a real build on the declared MSRV toolchain (`verify_msrv_build`):
    /// the crate declares `rust-version = X` but does NOT actually build on rustc X. Empty
    /// unless the heavy verify pass ran AND found a failure; a HARD gate signal when present.
    pub msrv_verify_errors: Vec<String>,
    /// P4 — errors from the docs.rs rehearsal (`cargo doc --all-features`, `docs_rehearsal`):
    /// docs.rs builds `--all-features` AFTER publish and a failure is IRREVERSIBLE (the
    /// version is immutable). Empty unless the rehearsal ran AND found a compile error; a
    /// HARD gate signal when present.
    pub docs_errors: Vec<String>,
}

impl EditionReport {
    /// Clean ⟺ no static findings, no 2024-compat lints, and (when the heavy P4 passes ran)
    /// no MSRV-verify or docs.rs failures. The ADVISORY `msrv_advisories` never red this —
    /// they inform, they do not block — so the DEFAULT [`EditionGateConfig`] (heavy passes
    /// off) preserves the historical `is_clean` semantics exactly.
    pub fn is_clean(&self) -> bool {
        self.static_findings.is_empty()
            && self.lints.is_empty()
            && self.msrv_verify_errors.is_empty()
            && self.docs_errors.is_empty()
    }
}

/// Toggles for the P4 heavy/opt-in edition passes. The DEFAULT keeps the historical gate
/// behavior: the cheap advisory dep-MSRV-raise DETECTION is ON (informational only, so it
/// cannot redden a green gate), while the TWO heavy passes — a build on the declared MSRV
/// toolchain and the `cargo doc --all-features` docs.rs rehearsal — are OFF (the doc places
/// them in the heavier gate/bench container that carries the MSRV toolchain + nightly
/// rustdoc). Turn them on for the container pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionGateConfig {
    /// Detect a dependency whose MSRV exceeds our declared floor (advisory). Cheap + pure.
    pub detect_dep_msrv_raise: bool,
    /// Verify the crate actually builds on its declared MSRV toolchain (`cargo +<msrv>
    /// check`). Heavy; needs the toolchain installed (degrades to a note if absent).
    pub verify_msrv_build: bool,
    /// Rehearse the docs.rs build (`cargo doc --all-features`) before the irreversible
    /// upload. Heavy; catches an all-features docs break that only bites post-publish.
    pub docs_rehearsal: bool,
}

impl Default for EditionGateConfig {
    fn default() -> Self {
        Self { detect_dep_msrv_raise: true, verify_msrv_build: false, docs_rehearsal: false }
    }
}

/// Parse `"1.85"` / `"1.85.0"` / `"1.85.1"` into `(major, minor)`, tolerating a
/// leading caret etc. Missing parts read as 0.
fn major_minor(s: &str) -> (u64, u64) {
    let cleaned = s.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
    let mut it = cleaned.split('.').map(|p| {
        p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse::<u64>().unwrap_or(0)
    });
    (it.next().unwrap_or(0), it.next().unwrap_or(0))
}

/// PURE: the static edition findings. Empty = every publishable package declares
/// edition 2024 + MSRV ≥ 1.85 and the workspace resolver is "3".
pub fn static_findings(pkgs: &[PkgEdition], resolver: Option<&str>) -> Vec<EditionFinding> {
    let mut out = Vec::new();
    for p in pkgs {
        if !p.publishable {
            continue;
        }
        if p.edition != "2024" {
            out.push(EditionFinding {
                package: p.name.clone(),
                issue: format!("edition = \"{}\" — want \"2024\"", p.edition),
            });
        }
        match &p.rust_version {
            None => out.push(EditionFinding {
                package: p.name.clone(),
                issue: "no rust-version (MSRV) — edition 2024 needs ≥ 1.85".into(),
            }),
            Some(rv) if major_minor(rv) < EDITION_MSRV => out.push(EditionFinding {
                package: p.name.clone(),
                issue: format!("rust-version = \"{rv}\" < 1.85 (edition 2024 floor)"),
            }),
            Some(_) => {}
        }
    }
    if resolver != Some("3") {
        out.push(EditionFinding {
            package: "[workspace]".into(),
            issue: format!(
                "resolver = {} — want \"3\" (the edition-2024 default)",
                resolver.map(|r| format!("\"{r}\"")).unwrap_or_else(|| "unset".into())
            ),
        });
    }
    out
}

/// PURE: pull `rust-2024-compatibility` diagnostics out of `cargo …
/// --message-format=json` stdout. Each line is one cargo JSON message; we keep
/// compiler-messages whose RENDERED text flags a 2024-edition incompatibility
/// (cargo phrases these as "… in the 2024 edition" / "Rust 2024"), which is robust
/// without hard-coding the (evolving) set of individual lint names.
pub fn parse_2024_lints(cargo_json_stdout: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in cargo_json_stdout.lines() {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
        if v.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
            continue;
        }
        let msg = &v["message"];
        let rendered = msg.get("rendered").and_then(|r| r.as_str()).unwrap_or("");
        let primary = msg.get("message").and_then(|m| m.as_str()).unwrap_or("");
        let hay = rendered.to_ascii_lowercase();
        if hay.contains("2024 edition") || hay.contains("rust 2024") || hay.contains("edition 2024")
        {
            let code = msg
                .get("code")
                .and_then(|c| c.get("code"))
                .and_then(|c| c.as_str())
                .unwrap_or("rust_2024_compatibility");
            out.push(format!("{code}: {primary}"));
        }
    }
    out
}

/// Read `resolver` from a workspace/package root `Cargo.toml` (workspace table
/// preferred, then package). `None` if unset/unreadable.
pub fn read_resolver(repo_root: &Path) -> Option<String> {
    let txt = std::fs::read_to_string(repo_root.join("Cargo.toml")).ok()?;
    let doc = txt.parse::<toml::Table>().map(toml::Value::Table).ok()?;
    doc.get("workspace")
        .and_then(|w| w.get("resolver"))
        .or_else(|| doc.get("package").and_then(|p| p.get("resolver")))
        .and_then(|r| r.as_str())
        .map(str::to_string)
}

/// The STATIC findings only (edition / MSRV / resolver), via `cargo_metadata` — no
/// `cargo check` subprocess. Cheap enough to run on a viz load (a few hundred ms),
/// unlike the dynamic lint pass. Used by the viz Gate pane's always-on view.
pub fn static_findings_only(repo_root: &Path) -> Result<Vec<EditionFinding>> {
    let meta = cargo_metadata::MetadataCommand::new()
        .current_dir(repo_root)
        .exec()
        .with_context(|| format!("cargo metadata in {}", repo_root.display()))?;
    let ws: std::collections::BTreeSet<_> = meta.workspace_members.iter().collect();
    let pkgs: Vec<PkgEdition> = meta
        .packages
        .iter()
        .filter(|p| ws.contains(&p.id))
        .map(|p| PkgEdition {
            name: p.name.to_string(),
            edition: p.edition.as_str().to_string(),
            rust_version: p.rust_version.as_ref().map(|v| v.to_string()),
            publishable: !matches!(&p.publish, Some(v) if v.is_empty()),
        })
        .collect();
    Ok(static_findings(&pkgs, read_resolver(repo_root).as_deref()))
}

// ─── P4: MSRV verification + docs.rs rehearsal ───────────────────────────────

/// PURE: the ADVISORY dep-MSRV-raise findings. `floor` is the workspace's declared MSRV
/// floor `(major, minor)`; `deps` are `(name, rust_version)` for every RESOLVED dependency.
/// A dep whose parsed `rust_version` is strictly ABOVE the floor SILENTLY raises the crate's
/// effective MSRV above what it promises — the sneaky vector the declared-floor check misses.
/// Deps with no `rust-version` (unknown MSRV) are not flagged (no evidence). Returns one note
/// per offending dep.
pub fn dep_msrv_advisories(floor: (u64, u64), deps: &[(String, Option<String>)]) -> Vec<String> {
    let mut out = Vec::new();
    for (name, rv) in deps {
        if let Some(rv) = rv {
            let dep_mm = major_minor(rv);
            if dep_mm > floor {
                out.push(format!(
                    "dependency `{name}` requires rustc {}.{} — ABOVE the declared MSRV floor \
                     {}.{}; it silently raises the effective MSRV. Raise `rust-version` (and \
                     verify the build) or pin `{name}` to an MSRV-compatible version.",
                    dep_mm.0, dep_mm.1, floor.0, floor.1
                ));
            }
        }
    }
    out
}

/// PURE: pull COMPILE ERRORS out of a `cargo doc … --message-format=json` stdout — each line
/// is one cargo JSON message; we keep compiler-messages at `level == "error"` (the docs.rs
/// rehearsal only cares whether the doc build COMPILES, `--all-features`). Robust across
/// toolchains (no hard-coded lint set). Mirrors [`parse_2024_lints`].
pub fn parse_doc_errors(cargo_json_stdout: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in cargo_json_stdout.lines() {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
        if v.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
            continue;
        }
        let msg = &v["message"];
        if msg.get("level").and_then(|l| l.as_str()) != Some("error") {
            continue;
        }
        let primary = msg.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
        let code = msg
            .get("code")
            .and_then(|c| c.get("code"))
            .and_then(|c| c.as_str())
            .unwrap_or("error");
        out.push(format!("{code}: {primary}"));
    }
    out
}

/// The workspace's declared MSRV FLOOR: the MINIMUM `rust-version` among publishable members
/// (the weakest promise the release makes). Members without a declared MSRV are ignored here
/// (the static check already flags a missing MSRV); if NONE declare one, the edition floor
/// (`1.85`) stands in.
fn declared_msrv_floor(pkgs: &[PkgEdition]) -> (u64, u64) {
    pkgs.iter()
        .filter(|p| p.publishable)
        .filter_map(|p| p.rust_version.as_deref().map(major_minor))
        .min()
        .unwrap_or(EDITION_MSRV)
}

/// Gather `(name, rust_version)` for every RESOLVED package (members + deps) from
/// `cargo metadata`, and the member [`PkgEdition`] rows — the inputs the P4 pure cores need.
fn metadata_msrv_inputs(repo_root: &Path) -> Result<(Vec<PkgEdition>, Vec<(String, Option<String>)>)> {
    let meta = cargo_metadata::MetadataCommand::new()
        .current_dir(repo_root)
        .exec()
        .with_context(|| format!("cargo metadata in {}", repo_root.display()))?;
    let ws: std::collections::BTreeSet<_> = meta.workspace_members.iter().collect();
    let members: Vec<PkgEdition> = meta
        .packages
        .iter()
        .filter(|p| ws.contains(&p.id))
        .map(|p| PkgEdition {
            name: p.name.to_string(),
            edition: p.edition.as_str().to_string(),
            rust_version: p.rust_version.as_ref().map(|v| v.to_string()),
            publishable: !matches!(&p.publish, Some(v) if v.is_empty()),
        })
        .collect();
    // Dependencies = every resolved package that is NOT a workspace member.
    let deps: Vec<(String, Option<String>)> = meta
        .packages
        .iter()
        .filter(|p| !ws.contains(&p.id))
        .map(|p| (p.name.to_string(), p.rust_version.as_ref().map(|v| v.to_string())))
        .collect();
    Ok((members, deps))
}

/// Static + dynamic gate for `repo_root`, DEFAULT config (historical behavior: 2024
/// static+lints plus the advisory dep-MSRV-raise detection; the heavy MSRV-verify + docs
/// rehearsal OFF). See [`run_gate_with`].
pub fn run_gate(repo_root: &Path) -> Result<EditionReport> {
    run_gate_with(repo_root, EditionGateConfig::default())
}

/// Static + dynamic gate for `repo_root` under `cfg`. Static always runs (cheap); the dynamic
/// lint pass, the MSRV build-verify, and the docs.rs rehearsal all shell out and are
/// best-effort — a tooling/environment failure degrades to a note (or `lint_pass_ran =
/// false`), never a false HARD gate.
pub fn run_gate_with(repo_root: &Path, cfg: EditionGateConfig) -> Result<EditionReport> {
    let static_findings = static_findings_only(repo_root)?;

    // Dynamic: force the whole 2024-compat group to warn, capture JSON.
    let (lints, lint_pass_ran) = match Command::new(crate::cargo::cargo_bin())
        .current_dir(repo_root)
        .args(["check", "--workspace", "--message-format=json"])
        .env("RUSTFLAGS", "--force-warn rust-2024-compatibility")
        .output()
    {
        Ok(o) => (parse_2024_lints(&String::from_utf8_lossy(&o.stdout)), true),
        Err(_) => (Vec::new(), false),
    };

    let mut msrv_advisories = Vec::new();
    let mut msrv_verify_errors = Vec::new();
    if cfg.detect_dep_msrv_raise || cfg.verify_msrv_build {
        if let Ok((members, deps)) = metadata_msrv_inputs(repo_root) {
            let floor = declared_msrv_floor(&members);
            if cfg.detect_dep_msrv_raise {
                msrv_advisories = dep_msrv_advisories(floor, &deps);
            }
            if cfg.verify_msrv_build {
                msrv_verify_errors = verify_msrv_build(repo_root, floor);
            }
        }
    }

    let docs_errors = if cfg.docs_rehearsal { docs_rehearsal(repo_root) } else { Vec::new() };

    Ok(EditionReport {
        static_findings,
        lints,
        lint_pass_ran,
        msrv_advisories,
        msrv_verify_errors,
        docs_errors,
    })
}

/// Best-effort: build the workspace on the declared MSRV toolchain (`cargo +<major.minor>
/// check`). A rustc-not-installed / spawn failure returns a single NOTE (never a false HARD
/// error); a non-zero build with real errors returns the rendered failures. The toolchain
/// spec `+1.85` resolves to the latest 1.85.x rustup has.
fn verify_msrv_build(repo_root: &Path, floor: (u64, u64)) -> Vec<String> {
    let toolchain = format!("+{}.{}", floor.0, floor.1);
    match Command::new(crate::cargo::cargo_bin())
        .current_dir(repo_root)
        .args([&toolchain, "check", "--workspace", "--message-format=json"])
        .output()
    {
        Ok(o) if o.status.success() => Vec::new(),
        Ok(o) => {
            let errs = parse_doc_errors(&String::from_utf8_lossy(&o.stdout));
            if errs.is_empty() {
                // Non-zero but no parseable compiler error ⇒ likely the toolchain is not
                // installed (`error: toolchain '1.85' is not installed`) — inconclusive note.
                vec![format!(
                    "MSRV verify inconclusive: `cargo {toolchain} check` exited {} (is the {}.{} \
                     toolchain installed? `rustup toolchain install {}.{}`)",
                    o.status, floor.0, floor.1, floor.0, floor.1
                )]
            } else {
                errs
            }
        }
        Err(e) => vec![format!("MSRV verify not runnable ({e}) — install the declared toolchain")],
    }
}

/// Best-effort: rehearse the docs.rs build with `cargo doc --all-features --no-deps` and
/// return any COMPILE ERRORS (docs.rs builds `--all-features` after publish; a break is
/// irreversible). A spawn failure returns a single note.
fn docs_rehearsal(repo_root: &Path) -> Vec<String> {
    match Command::new(crate::cargo::cargo_bin())
        .current_dir(repo_root)
        .args(["doc", "--workspace", "--all-features", "--no-deps", "--message-format=json"])
        .env("RUSTDOCFLAGS", "--cfg docsrs")
        .output()
    {
        Ok(o) => parse_doc_errors(&String::from_utf8_lossy(&o.stdout)),
        Err(e) => vec![format!("docs.rs rehearsal not runnable ({e})")],
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn pkg(name: &str, edition: &str, rv: Option<&str>, publishable: bool) -> PkgEdition {
        PkgEdition {
            name: name.into(),
            edition: edition.into(),
            rust_version: rv.map(str::to_string),
            publishable,
        }
    }

    #[test]
    fn clean_when_2024_msrv_and_resolver3() {
        let pkgs = [pkg("a", "2024", Some("1.85"), true), pkg("b", "2024", Some("1.86.0"), true)];
        assert!(static_findings(&pkgs, Some("3")).is_empty());
    }

    #[test]
    fn flags_old_edition_low_msrv_and_resolver() {
        let pkgs = [pkg("old", "2021", Some("1.80"), true)];
        let f = static_findings(&pkgs, Some("2"));
        // edition, msrv, resolver → 3 findings.
        assert_eq!(f.len(), 3, "{f:?}");
        assert!(f.iter().any(|x| x.package == "old" && x.issue.contains("edition")));
        assert!(f.iter().any(|x| x.package == "old" && x.issue.contains("< 1.85")));
        assert!(f.iter().any(|x| x.package == "[workspace]" && x.issue.contains("resolver")));
    }

    #[test]
    fn missing_msrv_is_flagged_and_resolver_unset() {
        let pkgs = [pkg("a", "2024", None, true)];
        let f = static_findings(&pkgs, None);
        assert!(f.iter().any(|x| x.issue.contains("no rust-version")));
        assert!(f.iter().any(|x| x.issue.contains("unset")));
    }

    #[test]
    fn unpublishable_packages_are_skipped() {
        // A publish=false crate on edition 2021 must NOT gate the release.
        let pkgs = [pkg("xtask", "2021", None, false)];
        assert!(static_findings(&pkgs, Some("3")).is_empty());
    }

    #[test]
    fn parse_2024_lints_picks_edition_diagnostics_only() {
        let json = [
            // a real 2024-compat warning (rendered mentions the 2024 edition)
            r#"{"reason":"compiler-message","message":{"code":{"code":"tail_expr_drop_order"},"message":"relative drop order changing","rendered":"warning: this will be a hard error in the 2024 edition"}}"#,
            // an unrelated warning — must be ignored
            r#"{"reason":"compiler-message","message":{"code":{"code":"unused_variables"},"message":"unused variable: x","rendered":"warning: unused variable: x"}}"#,
            // a non-message line
            r#"{"reason":"compiler-artifact"}"#,
        ]
        .join("\n");
        let lints = parse_2024_lints(&json);
        assert_eq!(lints.len(), 1, "only the 2024 diagnostic: {lints:?}");
        assert!(lints[0].contains("tail_expr_drop_order"));
    }

    #[test]
    fn major_minor_parses_versions() {
        assert_eq!(major_minor("1.85"), (1, 85));
        assert_eq!(major_minor("1.85.0"), (1, 85));
        assert_eq!(major_minor("1.84.9"), (1, 84));
        assert!((1, 84) < EDITION_MSRV && (1, 85) >= EDITION_MSRV);
    }

    /// RED-WHEN-BROKEN (P4): a dependency whose MSRV EXCEEDS the declared floor is flagged
    /// as a silent-MSRV-raise advisory; a dep AT/BELOW the floor, and a dep with no declared
    /// MSRV, are not. If [`dep_msrv_advisories`] stops comparing, the flags flip.
    #[test]
    fn dep_msrv_advisories_flag_a_silent_raise() {
        let floor = (1, 85);
        let deps = vec![
            ("greedy".to_string(), Some("1.90.0".to_string())), // ABOVE floor ⇒ flagged
            ("fine".to_string(), Some("1.85.0".to_string())),   // at floor ⇒ ok
            ("older".to_string(), Some("1.80.0".to_string())),  // below floor ⇒ ok
            ("unknown".to_string(), None),                      // no MSRV ⇒ no evidence
        ];
        let notes = dep_msrv_advisories(floor, &deps);

        let greedy_flagged = notes.iter().any(|n| n.contains("greedy") && n.contains("1.90"));
        let only_one = notes.len() == 1;
        let no_false = !notes.iter().any(|n| n.contains("fine") || n.contains("older") || n.contains("unknown"));

        let pass = greedy_flagged && only_one && no_false;
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "release-edition",
            "dep_msrv_advisories_flag_a_silent_raise",
            pass,
            &format!("greedy_flagged={greedy_flagged} only_one={only_one} no_false={no_false}"),
        );
        assert!(greedy_flagged, "a dep whose MSRV exceeds the floor is flagged");
        assert!(only_one, "exactly the one above-floor dep is flagged");
        assert!(no_false, "at/below-floor and unknown-MSRV deps are not flagged");
    }

    /// RED-WHEN-BROKEN (P4): the docs.rs-rehearsal parser keeps only compile ERRORS from
    /// `cargo doc --message-format=json`, ignoring warnings and non-message lines. If it
    /// stops filtering on `level == "error"`, the count changes.
    #[test]
    fn parse_doc_errors_keeps_only_errors() {
        let json = [
            r#"{"reason":"compiler-message","message":{"level":"error","code":{"code":"E0432"},"message":"unresolved import `foo`","rendered":"..."}}"#,
            r#"{"reason":"compiler-message","message":{"level":"warning","code":{"code":"unused"},"message":"unused import","rendered":"..."}}"#,
            r#"{"reason":"compiler-artifact"}"#,
        ]
        .join("\n");
        let errs = parse_doc_errors(&json);
        assert_eq!(errs.len(), 1, "only the error-level message: {errs:?}");
        assert!(errs[0].contains("E0432"), "carries the error code");
    }

    /// RED-WHEN-BROKEN (P4): the declared MSRV floor is the MINIMUM member `rust-version`;
    /// with none declared it falls back to the edition floor. And the default gate config
    /// keeps the heavy passes OFF so `is_clean` semantics are unchanged.
    #[test]
    fn declared_floor_is_min_and_default_config_keeps_heavy_off() {
        let pkgs = [
            pkg("a", "2024", Some("1.88"), true),
            pkg("b", "2024", Some("1.85"), true),
            pkg("xtask", "2024", Some("1.70"), false), // unpublishable ⇒ ignored
        ];
        assert_eq!(declared_msrv_floor(&pkgs), (1, 85), "floor = min publishable member MSRV");
        assert_eq!(declared_msrv_floor(&[]), EDITION_MSRV, "no members ⇒ edition floor");

        let cfg = EditionGateConfig::default();
        assert!(cfg.detect_dep_msrv_raise, "advisory dep-MSRV detection defaults ON");
        assert!(!cfg.verify_msrv_build, "the heavy MSRV build-verify defaults OFF");
        assert!(!cfg.docs_rehearsal, "the heavy docs.rs rehearsal defaults OFF");

        // A report with only advisories stays CLEAN (advisories never block); a docs/verify
        // error reds it.
        let mut r = EditionReport {
            lint_pass_ran: true,
            msrv_advisories: vec!["dep raises MSRV".into()],
            ..Default::default()
        };
        assert!(r.is_clean(), "advisories alone never red the gate");
        r.docs_errors.push("E0432: unresolved import".into());
        assert!(!r.is_clean(), "a docs.rs rehearsal error reds the gate");
    }

    /// RED-WHEN-BROKEN (#80): `EditionReport::default()` MUST be a CLEAN report — every vec
    /// empty, `is_clean() == true`. This is the contract that lets fallback/fixture and
    /// downstream (nornir) sites build via `..Default::default()` without a field addition
    /// silently reddening a would-be-clean report. If a new gate-affecting field is added
    /// whose zero value is NOT clean, this test goes red and forces an explicit decision.
    #[test]
    fn default_report_is_clean() {
        let r = EditionReport::default();
        assert!(r.is_clean(), "the Default EditionReport is clean by construction");
        assert!(r.static_findings.is_empty());
        assert!(r.lints.is_empty());
        assert!(r.msrv_advisories.is_empty());
        assert!(r.msrv_verify_errors.is_empty());
        assert!(r.docs_errors.is_empty());
        assert!(!r.lint_pass_ran, "no lint pass ran on a default report");
    }
}