src/viz/gate.rs
//! Shared **Gate / Doctor** model — the go/no-go release verdict computed once
//! from [`release::doctor::run`](crate::release::doctor::run) (dirty trees,
//! external-dep skew with `held_by_transitive_pin`, cycle-break advice, topo +
//! repo edges) plus [`release::edition`](nornir_release_core::edition) static findings.
//!
//! Lifted out of `release_tab` so BOTH the 🚀 Release pane AND the 🧬 nornir
//! RELEASE DASHBOARD share ONE gate computation + ONE `gate_json` shape — the
//! dashboard must not recompute or re-shape what the Release pane already ships.
use std::path::PathBuf;
use crate::release::doctor::{self, DepPolicy, DoctorReport};
use nornir_release_core::edition::{self, EditionReport};
/// The cached **Gate / Doctor** model — the advisory release report (doctor) plus
/// the edition-2024 gate result, computed on load/reload (NOT every repaint) so a
/// pane can render dirty trees, external-dep skew, cycle-break advice, and edition
/// findings without re-shelling cargo or re-scanning Cargo.tomls per frame.
pub struct GateModel {
/// The doctor advisory (dirty / skew / topo / blast / cycle_advice / repo_edges).
pub doctor: DoctorReport,
/// The edition-2024 gate. The always-on view uses only `static_findings`; the
/// dynamic lint pass (which shells `cargo check`) is opt-in behind a button and
/// fills `lints` / `lint_pass_ran` when run.
pub edition: EditionReport,
}
impl GateModel {
/// Build directly from a doctor + edition report (the test-inject seam).
pub fn new(doctor: DoctorReport, edition: EditionReport) -> Self {
Self { doctor, edition }
}
/// Compute the gate model from a workspace's repo checkouts. The edition
/// dynamic lint pass (`cargo check`) only runs when `lint` — otherwise only
/// the cheap `static_findings_only` is read (the perf caveat). Local only.
/// Returns `Err` with the doctor's failure string.
pub fn compute(
repos: &[(String, PathBuf)],
policy: &DepPolicy,
lint: bool,
) -> Result<Self, String> {
let doctor = match doctor::run(repos, policy) {
Ok(d) => d,
Err(e) => {
let msg = format!("{e:#}");
nornir_testmatrix::functional_status(
"nornir/viz/gate",
"gate_computed",
false,
&msg,
);
return Err(msg);
}
};
let edition = if lint {
let root = repos.first().map(|(_, p)| p.clone());
match root.as_deref() {
Some(p) => edition::run_gate(p).unwrap_or(EditionReport {
static_findings: Vec::new(),
lints: Vec::new(),
lint_pass_ran: false,
msrv_advisories: Vec::new(),
msrv_verify_errors: Vec::new(),
docs_errors: Vec::new(),
}),
None => EditionReport {
static_findings: Vec::new(),
lints: Vec::new(),
lint_pass_ran: false,
msrv_advisories: Vec::new(),
msrv_verify_errors: Vec::new(),
docs_errors: Vec::new(),
},
}
} else {
let root = repos.first().map(|(_, p)| p.clone());
let static_findings = root
.as_deref()
.and_then(|p| edition::static_findings_only(p).ok())
.unwrap_or_default();
EditionReport {
static_findings,
lints: Vec::new(),
lint_pass_ran: false,
msrv_advisories: Vec::new(),
msrv_verify_errors: Vec::new(),
docs_errors: Vec::new(),
}
};
let model = Self { doctor, edition };
// EMIT-DOCTRINE: the shared Gate/Doctor model is computed once on load/reload
// (not per frame) — this marks the FUNCTION of the computation (a model was
// produced), not the go/no-go verdict; a doctor::run failure above is the RED.
nornir_testmatrix::functional_status(
"nornir/viz/gate",
"gate_computed",
true,
&format!(
"{} skew, {} held_back, gate_ok={}",
model.doctor.skew.len(),
model.held_back().len(),
model.gate_ok(),
),
);
Ok(model)
}
/// Repos behind on a crate AND held back by a transitive pin (the ⛔ rows) —
/// the names the robot test asserts on.
pub fn held_back(&self) -> Vec<String> {
let mut out: Vec<String> = self
.doctor
.skew
.iter()
.flat_map(|c| {
c.entries
.iter()
.filter(|e| e.held_by_transitive_pin)
.map(|e| e.repo.clone())
})
.collect();
out.sort();
out.dedup();
out
}
/// Dirty working trees (advisory).
pub fn dirty(&self) -> Vec<String> {
self.doctor
.dirty
.iter()
.filter(|d| d.dirty)
.map(|d| d.repo.clone())
.collect()
}
/// The overall go/no-go verdict: a gate FAILS (no-go) when the edition gate is
/// dirty OR any repo is held back by a transitive pin (the blocking ⛔ rows).
/// Dirty trees + plain skew are advisory and do NOT block on their own.
pub fn gate_ok(&self) -> bool {
self.edition.is_clean() && self.held_back().is_empty()
}
/// The structured **Gate / Doctor** block folded into `state_json` — the SAME
/// shape the 🚀 Release pane ships. Stable keys; the `present`/zeroed variant
/// for an absent model lives in [`gate_json_absent`].
pub fn gate_json(&self, error: Option<&str>) -> serde_json::Value {
let held_back = self.held_back();
let dirty = self.dirty();
let edition_findings = self.edition.static_findings.len();
let edition_clean = self.edition.is_clean();
let cycle_first = self
.doctor
.cycle_advice
.first()
.map(|a| serde_json::Value::String(a.rationale.clone()))
.unwrap_or(serde_json::Value::Null);
serde_json::json!({
"present": true,
"error": error,
"skew_count": self.doctor.skew.len(),
"held_back": held_back,
"cycle_advice": self.doctor.cycle_advice.len(),
"cycle_advice_first": cycle_first,
"edition_clean": edition_clean,
"edition_findings": edition_findings,
"edition_lint_ran": self.edition.lint_pass_ran,
"edition_lints": self.edition.lints.len(),
"dirty": dirty,
"gate_ok": self.gate_ok(),
})
}
}
/// The `gate_json` shape for an ABSENT model (remote / unconfigured) — the same
/// stable keys, zeroed, with `present: false`.
pub fn gate_json_absent(error: Option<&str>) -> serde_json::Value {
serde_json::json!({
"present": false,
"error": error,
"skew_count": 0,
"held_back": [],
"cycle_advice": 0,
"cycle_advice_first": serde_json::Value::Null,
"edition_clean": true,
"edition_findings": 0,
"edition_lint_ran": false,
"edition_lints": 0,
"dirty": [],
"gate_ok": true,
})
}