Skip to main content

fallow_output/
gate_outcomes.rs

1//! The machine-readable verdict of every gate a run evaluated.
2//!
3//! One shape for every command that can fail a build, so a consumer reads the
4//! same members whether the envelope came from `dead-code`, `dupes`, `health`,
5//! `audit` or `security`. Carried as `gate_outcomes` at the envelope root,
6//! absent whenever the run evaluated no gate.
7//!
8//! Every entry is a projection of the rule that decides the exit code, computed
9//! once and then read by the exit path, so nothing here restates a rule that
10//! lives elsewhere. `stale-baseline` projects the run's `BaselineStaleness`,
11//! `regression` its `RegressionResult`, and `security` its `SecurityGate`
12//! verdict. That is the point of the object: a CI integration reads one member
13//! instead of reimplementing a condition in jq, and a gate added in a later
14//! release reaches an unchanged consumer.
15//!
16//! # Why this is not the `gates` array on the pull-request decision surface
17//!
18//! [`crate::pr_decision::PrDecisionSurface`] already publishes a `gates` array
19//! whose members carry `label`, `observed` and `threshold` as display text for
20//! the GitHub check run. The two answer different questions and will not
21//! converge: that one is a rendering contract for a human-facing summary, this
22//! one is a machine verdict a build gates on. Hence the different key
23//! (`gate_outcomes`, not `gates`) and the absence of any prose member here.
24//!
25//! The display array is DERIVED from this object for the gates a run armed, so
26//! a tripped gate reaches the check run as a named gate rather than as a failed
27//! step. That is a one-way projection into display text: a consumer that needs
28//! the verdict reads this object, never the rendered row.
29//!
30//! # Wire compatibility
31//!
32//! The key set is OPEN. A name this build does not recognise means "some gate",
33//! not an error, the same tolerate-unknown-values contract
34//! `workspace_diagnostics[].kind` documents. The Rust key type is a closed enum
35//! so the emitter cannot drift, and a gate added later is an additive optional
36//! key that bumps no `schema_version`.
37
38use std::collections::BTreeMap;
39
40use serde::Serialize;
41
42/// Which gate an outcome describes.
43///
44/// Taken from the exit-code sites rather than from any integration's input
45/// list, because a gate a consumer cannot name is exactly the one whose verdict
46/// goes missing. Serialized as kebab-case and published as an open set.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49#[serde(rename_all = "kebab-case")]
50pub enum GateName {
51    /// The CLI's own severity rule: any finding whose effective severity is
52    /// `error` fails the run. This is NOT a count rule, and `--fail-on-issues`
53    /// only promotes warn-tier rules into it, so a project with a rule set to
54    /// `warn` can report findings and still exit 0.
55    ErrorSeverityFindings,
56    /// `--fail-on-regression`: issue counts grew past `--tolerance` compared
57    /// with the regression baseline.
58    Regression,
59    /// `--fail-on-stale-baseline`: the loaded baseline has entries that matched
60    /// nothing this run.
61    StaleBaseline,
62    /// `--threshold`: duplication exceeded the configured percentage.
63    DuplicationThreshold,
64    /// `--min-score`: the health score fell below the configured minimum.
65    HealthMinScore,
66    /// `--min-severity`: at least one complexity finding reached the configured
67    /// severity. One branch of the findings gate; see [`Self::HealthFindings`].
68    HealthMinSeverity,
69    /// The health findings gate with no severity floor: any complexity finding
70    /// fails the run. Inert when `--min-score` is set alone, which is what
71    /// "complexity findings become informational" means.
72    HealthFindings,
73    /// The coverage-gap gate, configured through `rules.coverage-gaps`.
74    HealthCoverageGaps,
75    /// A runtime-coverage finding whose verdict is `safe_to_delete`,
76    /// `review_required` or `low_traffic`.
77    HealthRuntimeCoverage,
78    /// `security --gate`: the change introduced a new security-sink candidate.
79    /// The only gate that exits 8 rather than 1.
80    Security,
81    /// The security advisory exit, which fails on the candidate backlog rather
82    /// than on what the change introduced. A configured `--gate` returns before
83    /// it, so this reports `skipped` on any run that set one.
84    SecurityAdvisory,
85    /// `fallow audit`'s rule-severity verdict, the only three-valued gate.
86    AuditVerdict,
87    /// `--type-aware-require complete`: semantic analysis was partial or
88    /// unavailable.
89    TypeAwareRequire,
90}
91
92impl GateName {
93    /// Every gate name this build can emit, in declaration order.
94    ///
95    /// Exists so a surface that has to cover the set exhaustively, such as the
96    /// pull-request decision surface's display labels, can be tested against
97    /// the emitter rather than against a hand-kept list. A new variant belongs
98    /// here as well as in [`Self::as_str`], whose match will not compile until
99    /// it is named.
100    pub const ALL: [Self; 13] = [
101        Self::ErrorSeverityFindings,
102        Self::Regression,
103        Self::StaleBaseline,
104        Self::DuplicationThreshold,
105        Self::HealthMinScore,
106        Self::HealthMinSeverity,
107        Self::HealthFindings,
108        Self::HealthCoverageGaps,
109        Self::HealthRuntimeCoverage,
110        Self::Security,
111        Self::SecurityAdvisory,
112        Self::AuditVerdict,
113        Self::TypeAwareRequire,
114    ];
115
116    /// The kebab-case key this gate serializes as, for prose and lookups
117    /// outside the JSON envelope.
118    #[must_use]
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::ErrorSeverityFindings => "error-severity-findings",
122            Self::Regression => "regression",
123            Self::StaleBaseline => "stale-baseline",
124            Self::DuplicationThreshold => "duplication-threshold",
125            Self::HealthMinScore => "health-min-score",
126            Self::HealthMinSeverity => "health-min-severity",
127            Self::HealthFindings => "health-findings",
128            Self::HealthCoverageGaps => "health-coverage-gaps",
129            Self::HealthRuntimeCoverage => "health-runtime-coverage",
130            Self::Security => "security",
131            Self::SecurityAdvisory => "security-advisory",
132            Self::AuditVerdict => "audit-verdict",
133            Self::TypeAwareRequire => "type-aware-require",
134        }
135    }
136}
137
138/// What a gate concluded on this run.
139///
140/// Four-valued rather than a boolean because audit's verdict has a warn tier
141/// (`crates/cli/src/cli_report.rs` maps it onto three conclusions) and because
142/// a gate can stand down without passing. Widening a published boolean later
143/// would retype a required field and bump every carrying envelope, so the width
144/// is decided here.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147#[serde(rename_all = "kebab-case")]
148pub enum GateStatus {
149    /// The gate ran and its condition did not hold.
150    Pass,
151    /// The gate ran and reached a warn tier that does not fail the run. Only
152    /// `audit-verdict` can report this today.
153    Warn,
154    /// The gate ran and its condition held.
155    Fail,
156    /// The gate could not judge this run and deliberately stood down: a
157    /// change-scoped baseline comparison, `health --report-only`, or a security
158    /// advisory shadowed by a configured gate. Distinct from `pass`, which
159    /// asserts the condition was evaluated and did not hold.
160    Skipped,
161}
162
163/// One gate's verdict on one run.
164///
165/// `status` and `enforced` answer different questions and legitimately
166/// disagree. `status` is what the rule concluded; `enforced` is whether a
167/// `fail` from this gate would make the run exit non-zero. A
168/// `health --report-only` run is an explicit request never to fail, so a
169/// failing gate there reports `status: fail` with `enforced: false`, and a
170/// stale-baseline verdict published without `--fail-on-stale-baseline` reports
171/// the same pair.
172///
173/// **A gate fails the build when `status` is `fail` AND `enforced` is true.**
174/// Neither member decides it alone: `enforced` is true on every armed gate,
175/// including the ones that passed, so gating on it by itself fails every run
176/// that armed anything. Read `status` on its own to decide what to say, and
177/// remember that `warn` and `skipped` are neither a pass nor a failure.
178#[derive(Debug, Clone, PartialEq, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180pub struct GateOutcome {
181    /// What the rule concluded.
182    pub status: GateStatus,
183    /// True when a `fail` from this gate makes the run exit non-zero. False
184    /// when the verdict is published for information only, because the gate was
185    /// never armed or because the run was told never to fail.
186    pub enforced: bool,
187    /// The measured value the gate compared, when there is one: the duplication
188    /// percentage, the health score, or the number of findings at or above the
189    /// severity floor. Whole numbers are carried as JSON numbers, so a count of
190    /// three reads as `3.0`. Absent for gates that compare no number.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub observed: Option<f64>,
193    /// The configured limit `observed` was compared against, when there is one.
194    /// Absent for gates that compare no number.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub threshold: Option<f64>,
197    /// How the limit was spelled, for a gate whose `threshold` number does not
198    /// carry its own unit. `health-min-severity` sets it to the severity floor
199    /// (`moderate`, `high` or `critical`); `regression` sets it to the
200    /// tolerance as the user wrote it (`"50%"` or `"5"`), because `threshold`
201    /// there is the allowance in issues and the percentage would otherwise be
202    /// unrecoverable on the grouped envelope, which carries no `regression`
203    /// object. Absent for gates whose numbers speak for themselves.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub threshold_label: Option<String>,
206}
207
208impl GateOutcome {
209    /// A gate that ran, compared no number, and was armed.
210    #[must_use]
211    pub const fn new(status: GateStatus, enforced: bool) -> Self {
212        Self {
213            status,
214            enforced,
215            observed: None,
216            threshold: None,
217            threshold_label: None,
218        }
219    }
220
221    /// A gate that compared `observed` against `threshold`.
222    #[must_use]
223    pub const fn measured(
224        status: GateStatus,
225        enforced: bool,
226        observed: f64,
227        threshold: f64,
228    ) -> Self {
229        Self {
230            status,
231            enforced,
232            observed: Some(observed),
233            threshold: Some(threshold),
234            threshold_label: None,
235        }
236    }
237
238    /// A gate that counted `observed` items at or above a named floor.
239    #[must_use]
240    pub fn counted(
241        status: GateStatus,
242        enforced: bool,
243        observed: f64,
244        threshold_label: &str,
245    ) -> Self {
246        Self {
247            status,
248            enforced,
249            observed: Some(observed),
250            threshold: None,
251            threshold_label: Some(threshold_label.to_owned()),
252        }
253    }
254
255    /// Whether this outcome should make the run exit non-zero.
256    #[must_use]
257    pub const fn fails_run(&self) -> bool {
258        self.enforced && matches!(self.status, GateStatus::Fail)
259    }
260}
261
262/// Every gate a run ARMED, keyed by name.
263///
264/// Armed, not evaluated: a gate is armed by a flag or by config, never merely
265/// because the rule behind it exists. Fallow's default severity rules fail a
266/// run with no flag at all, so a `dead-code` run can exit 1 carrying no object
267/// whatsoever. Read an absent object as "no gate was asked for", never as
268/// "nothing failed".
269///
270/// Absent from an envelope whenever it is empty, so a run that armed no gate is
271/// byte-identical to one produced before this object existed. An empty object
272/// is never emitted: it would assert that gates were armed and none tripped,
273/// which is a different and false claim.
274///
275/// The names this build can emit are `error-severity-findings`, `regression`,
276/// `stale-baseline`, `duplication-threshold`, `health-min-score`,
277/// `health-min-severity`, `health-findings`, `health-coverage-gaps`,
278/// `health-runtime-coverage`, `security`, `security-advisory`, `audit-verdict`
279/// and `type-aware-require`. The set is OPEN: a name a consumer does not
280/// recognise means "some gate", not an error.
281#[derive(Debug, Clone, Default, PartialEq, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(transparent)]
284pub struct GateOutcomes(BTreeMap<GateName, GateOutcome>);
285
286impl GateOutcomes {
287    /// An empty set, which serializes to nothing once [`Self::into_option`]
288    /// has been applied.
289    #[must_use]
290    pub fn new() -> Self {
291        Self(BTreeMap::new())
292    }
293
294    /// Record one gate's outcome, replacing any previous entry for that name.
295    pub fn insert(&mut self, name: GateName, outcome: GateOutcome) {
296        self.0.insert(name, outcome);
297    }
298
299    /// Record one gate's outcome when it ran at all.
300    pub fn insert_if(&mut self, name: GateName, outcome: Option<GateOutcome>) {
301        if let Some(outcome) = outcome {
302            self.0.insert(name, outcome);
303        }
304    }
305
306    /// Read one gate's outcome.
307    #[must_use]
308    pub fn get(&self, name: GateName) -> Option<&GateOutcome> {
309        self.0.get(&name)
310    }
311
312    /// Whether the set holds no gate.
313    #[must_use]
314    pub fn is_empty(&self) -> bool {
315        self.0.is_empty()
316    }
317
318    /// Whether any recorded gate should make the run exit non-zero.
319    #[must_use]
320    pub fn any_fails_run(&self) -> bool {
321        self.0.values().any(GateOutcome::fails_run)
322    }
323
324    /// Collapse an empty set to `None`, which is how the field stays absent on
325    /// a run that evaluated no gate.
326    #[must_use]
327    pub fn into_option(self) -> Option<Self> {
328        if self.0.is_empty() { None } else { Some(self) }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn empty_set_collapses_to_absent() {
338        assert!(GateOutcomes::new().into_option().is_none());
339    }
340
341    #[test]
342    fn populated_set_survives_collapse() {
343        let mut gates = GateOutcomes::new();
344        gates.insert(
345            GateName::Regression,
346            GateOutcome::new(GateStatus::Fail, true),
347        );
348        assert!(gates.into_option().is_some());
349    }
350
351    /// `ALL` is the list a consumer covering the set exhaustively is tested
352    /// against, so a variant missing from it would let a new gate reach the
353    /// wire with no surface knowing about it.
354    #[test]
355    fn every_name_in_all_is_distinct_and_spelled_as_serde_spells_it() {
356        let mut names = GateName::ALL.map(GateName::as_str).to_vec();
357        let total = names.len();
358        names.sort_unstable();
359        names.dedup();
360        assert_eq!(names.len(), total, "a duplicated entry hides one variant");
361
362        for name in GateName::ALL {
363            assert_eq!(
364                serde_json::to_value(name).expect("name serializes"),
365                serde_json::json!(name.as_str())
366            );
367        }
368    }
369
370    #[test]
371    fn names_serialize_as_kebab_case() {
372        let mut gates = GateOutcomes::new();
373        gates.insert(
374            GateName::ErrorSeverityFindings,
375            GateOutcome::new(GateStatus::Pass, true),
376        );
377        gates.insert(
378            GateName::HealthMinScore,
379            GateOutcome::measured(GateStatus::Fail, true, 85.0, 90.0),
380        );
381        let value = serde_json::to_value(&gates).expect("gate outcomes serialize");
382        assert_eq!(
383            value,
384            serde_json::json!({
385                "error-severity-findings": { "status": "pass", "enforced": true },
386                "health-min-score": {
387                    "status": "fail",
388                    "enforced": true,
389                    "observed": 85.0,
390                    "threshold": 90.0
391                }
392            })
393        );
394    }
395
396    #[test]
397    fn an_unenforced_failure_does_not_fail_the_run() {
398        let outcome = GateOutcome::new(GateStatus::Fail, false);
399        assert!(!outcome.fails_run());
400        let enforced = GateOutcome::new(GateStatus::Fail, true);
401        assert!(enforced.fails_run());
402    }
403
404    #[test]
405    fn a_skipped_gate_never_fails_the_run() {
406        assert!(!GateOutcome::new(GateStatus::Skipped, true).fails_run());
407        assert!(!GateOutcome::new(GateStatus::Warn, true).fails_run());
408    }
409}