1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
//! 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`](crate::release::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 crate::release::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 = doctor::run(repos, policy).map_err(|e| format!("{e:#}"))?;
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,
}),
None => EditionReport {
static_findings: Vec::new(),
lints: Vec::new(),
lint_pass_ran: false,
},
}
} 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 }
};
Ok(Self { doctor, edition })
}
/// 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,
})
}