Skip to main content

gate_contract/
lib.rs

1//! Fail-closed gate contracts for staged build pipelines.
2//!
3//! A pipeline that only knows PASS and FAIL has a third state hiding inside it: the gate
4//! that could not be evaluated at all, because a credential was missing, an API timed out,
5//! or the value it needed was never produced. Treating that as a pass is how a run reaches
6//! deploy having checked nothing. This crate makes the third state explicit and closes it.
7//!
8//! The rule enforced throughout:
9//!
10//! > A **blocking** gate halts the run when it fails **and** when it cannot be evaluated.
11//! > "Could not check" is not "fine".
12//!
13//! # The pieces
14//!
15//! - [`Outcome`] — `Pass`, `Fail`, or `Unevaluable`, each carrying a reason string.
16//! - [`Known`] — a value that may be `Unknown`; unknown propagates through combination
17//!   instead of decaying into a default.
18//! - [`Gate`] — an id, a [`Severity`] (blocking or advisory) and an outcome.
19//! - [`Stage`] — a named set of gates, evaluated together so one run reports every failure.
20//! - [`Pipeline`] — ordered stages, halting at the first stage that halts.
21//!
22//! # Example
23//!
24//! ```
25//! use gate_contract::{Gate, Severity, Stage, Verdict};
26//!
27//! let stage = Stage::new("harvest")
28//!     .with_gate(Gate::pass("serp-rows-present"))
29//!     .with_gate(Gate::unevaluable("rdap-reachable", "no credential on disk"));
30//!
31//! let report = stage.evaluate();
32//! assert!(report.halted());
33//! match report.verdict {
34//!     Verdict::Halt { ref gate_id, .. } => assert_eq!(gate_id, "rdap-reachable"),
35//!     Verdict::Proceed => unreachable!(),
36//! }
37//! ```
38//!
39//! An advisory gate records the same information without stopping anything:
40//!
41//! ```
42//! use gate_contract::{Gate, Stage};
43//!
44//! let report = Stage::new("design")
45//!     .with_gate(Gate::fail("tone-check", "two sentences flagged").advisory())
46//!     .evaluate();
47//! assert!(!report.halted());
48//! assert_eq!(report.warnings().len(), 1);
49//! ```
50//!
51//! No dependencies, no I/O, no unsafe. Evaluating what a gate *means* is this crate's job;
52//! deciding what to measure is yours.
53//!
54//! Extracted from the build pipeline described at <https://aiwebsitepipeline.com/>.
55
56#![forbid(unsafe_code)]
57#![deny(missing_docs)]
58
59use std::fmt;
60
61/// Whether a gate can stop the run.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum Severity {
64    /// Failure or non-evaluation halts the run.
65    Blocking,
66    /// Failure is recorded as a warning and the run continues.
67    Advisory,
68}
69
70impl Severity {
71    /// Whether this severity can halt a run.
72    pub fn is_blocking(self) -> bool {
73        matches!(self, Severity::Blocking)
74    }
75}
76
77impl fmt::Display for Severity {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Severity::Blocking => f.write_str("blocking"),
81            Severity::Advisory => f.write_str("advisory"),
82        }
83    }
84}
85
86/// The result of evaluating one gate.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Outcome {
89    /// The condition was checked and held.
90    Pass,
91    /// The condition was checked and did not hold.
92    Fail(String),
93    /// The condition could not be checked at all.
94    Unevaluable(String),
95}
96
97impl Outcome {
98    /// Whether the check ran to a verdict, pass or fail.
99    pub fn was_evaluated(&self) -> bool {
100        !matches!(self, Outcome::Unevaluable(_))
101    }
102
103    /// Whether the condition held.
104    pub fn passed(&self) -> bool {
105        matches!(self, Outcome::Pass)
106    }
107
108    /// The reason attached to a fail or a non-evaluation, if any.
109    pub fn reason(&self) -> Option<&str> {
110        match self {
111            Outcome::Pass => None,
112            Outcome::Fail(r) | Outcome::Unevaluable(r) => Some(r.as_str()),
113        }
114    }
115}
116
117impl fmt::Display for Outcome {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            Outcome::Pass => f.write_str("PASS"),
121            Outcome::Fail(r) => write!(f, "FAIL: {r}"),
122            Outcome::Unevaluable(r) => write!(f, "UNEVALUABLE: {r}"),
123        }
124    }
125}
126
127/// Why a run halted.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum HaltCause {
130    /// A blocking gate was checked and did not hold.
131    BlockingFailure,
132    /// A blocking gate could not be checked.
133    BlockingUnevaluable,
134}
135
136impl fmt::Display for HaltCause {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            HaltCause::BlockingFailure => f.write_str("blocking gate failed"),
140            HaltCause::BlockingUnevaluable => f.write_str("blocking gate could not be evaluated"),
141        }
142    }
143}
144
145/// What the caller should do next.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum Verdict {
148    /// Nothing blocking objected; continue.
149    Proceed,
150    /// Stop, and here is the first gate that said so.
151    Halt {
152        /// Id of the gate that halted the run.
153        gate_id: String,
154        /// Whether it failed or could not be evaluated.
155        cause: HaltCause,
156        /// The gate's reason string.
157        reason: String,
158    },
159}
160
161impl Verdict {
162    /// Whether this verdict stops the run.
163    pub fn is_halt(&self) -> bool {
164        matches!(self, Verdict::Halt { .. })
165    }
166}
167
168/// One contract: an identifier, a severity and an outcome.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Gate {
171    /// Stable identifier, e.g. `"rdap-reachable"`.
172    pub id: String,
173    /// Whether this gate can halt the run.
174    pub severity: Severity,
175    /// The evaluated outcome.
176    pub outcome: Outcome,
177}
178
179impl Gate {
180    /// A blocking gate with an explicit outcome.
181    pub fn new(id: impl Into<String>, outcome: Outcome) -> Self {
182        Gate {
183            id: id.into(),
184            severity: Severity::Blocking,
185            outcome,
186        }
187    }
188
189    /// A blocking gate that passed.
190    pub fn pass(id: impl Into<String>) -> Self {
191        Gate::new(id, Outcome::Pass)
192    }
193
194    /// A blocking gate that failed, with the reason.
195    pub fn fail(id: impl Into<String>, reason: impl Into<String>) -> Self {
196        Gate::new(id, Outcome::Fail(reason.into()))
197    }
198
199    /// A blocking gate that could not be checked, with the reason.
200    pub fn unevaluable(id: impl Into<String>, reason: impl Into<String>) -> Self {
201        Gate::new(id, Outcome::Unevaluable(reason.into()))
202    }
203
204    /// Downgrade this gate to advisory.
205    pub fn advisory(mut self) -> Self {
206        self.severity = Severity::Advisory;
207        self
208    }
209
210    /// Build a gate from a boolean check, with the reason used when it is false.
211    ///
212    /// ```
213    /// use gate_contract::Gate;
214    /// let g = Gate::check("page-count-nonzero", 12 > 0, "no pages were produced");
215    /// assert!(g.outcome.passed());
216    /// ```
217    pub fn check(id: impl Into<String>, held: bool, reason: impl Into<String>) -> Self {
218        if held {
219            Gate::pass(id)
220        } else {
221            Gate::fail(id, reason)
222        }
223    }
224
225    /// Build a gate from a value that may be unknown.
226    ///
227    /// `Known(v)` is handed to `predicate`; `Unknown` short-circuits to
228    /// [`Outcome::Unevaluable`], which is what makes the missing input halt a blocking
229    /// gate instead of silently passing it.
230    ///
231    /// ```
232    /// use gate_contract::{Gate, Known, Outcome};
233    ///
234    /// let measured: Known<u32> = Known::Unknown;
235    /// let g = Gate::check_known("coverage-above-floor", &measured, |c| *c >= 50, "below floor");
236    /// assert!(matches!(g.outcome, Outcome::Unevaluable(_)));
237    /// ```
238    pub fn check_known<T, F>(
239        id: impl Into<String>,
240        value: &Known<T>,
241        predicate: F,
242        reason: impl Into<String>,
243    ) -> Self
244    where
245        F: FnOnce(&T) -> bool,
246    {
247        match value {
248            Known::Unknown => Gate::unevaluable(id, "value is unknown"),
249            Known::Known(v) => Gate::check(id, predicate(v), reason),
250        }
251    }
252
253    /// Whether this gate, on its own, halts the run.
254    pub fn halts(&self) -> bool {
255        self.severity.is_blocking() && !self.outcome.passed()
256    }
257
258    fn halt_cause(&self) -> Option<HaltCause> {
259        if !self.halts() {
260            return None;
261        }
262        match self.outcome {
263            Outcome::Fail(_) => Some(HaltCause::BlockingFailure),
264            Outcome::Unevaluable(_) => Some(HaltCause::BlockingUnevaluable),
265            Outcome::Pass => None,
266        }
267    }
268}
269
270impl fmt::Display for Gate {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        write!(f, "[{}] {} {}", self.severity, self.id, self.outcome)
273    }
274}
275
276/// A value that may not exist yet.
277///
278/// The point of a dedicated type is that `Unknown` cannot be mistaken for a zero or an
279/// empty string once it reaches a comparison. Combining anything with `Unknown` yields
280/// `Unknown`.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum Known<T> {
283    /// The value was produced.
284    Known(T),
285    /// The value was not produced.
286    #[default]
287    Unknown,
288}
289
290impl<T> Known<T> {
291    /// Whether a value is present.
292    pub fn is_known(&self) -> bool {
293        matches!(self, Known::Known(_))
294    }
295
296    /// Borrow the value if present.
297    pub fn get(&self) -> Option<&T> {
298        match self {
299            Known::Known(v) => Some(v),
300            Known::Unknown => None,
301        }
302    }
303
304    /// Map the value, keeping `Unknown` unknown.
305    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Known<U> {
306        match self {
307            Known::Known(v) => Known::Known(f(v)),
308            Known::Unknown => Known::Unknown,
309        }
310    }
311
312    /// Combine two values; if either is unknown the result is unknown.
313    ///
314    /// ```
315    /// use gate_contract::Known;
316    /// let a = Known::Known(2);
317    /// let b: Known<i32> = Known::Unknown;
318    /// assert_eq!(a.zip_with(b, |x, y| x + y), Known::Unknown);
319    /// ```
320    pub fn zip_with<U, R, F: FnOnce(T, U) -> R>(self, other: Known<U>, f: F) -> Known<R> {
321        match (self, other) {
322            (Known::Known(a), Known::Known(b)) => Known::Known(f(a, b)),
323            _ => Known::Unknown,
324        }
325    }
326}
327
328impl<T> From<Option<T>> for Known<T> {
329    fn from(opt: Option<T>) -> Self {
330        match opt {
331            Some(v) => Known::Known(v),
332            None => Known::Unknown,
333        }
334    }
335}
336
337/// A named set of gates evaluated together.
338#[derive(Debug, Clone, Default, PartialEq, Eq)]
339pub struct Stage {
340    /// Stage name, e.g. `"harvest"`.
341    pub name: String,
342    /// The gates belonging to this stage.
343    pub gates: Vec<Gate>,
344}
345
346impl Stage {
347    /// An empty stage.
348    pub fn new(name: impl Into<String>) -> Self {
349        Stage {
350            name: name.into(),
351            gates: Vec::new(),
352        }
353    }
354
355    /// Append a gate, builder style.
356    pub fn with_gate(mut self, gate: Gate) -> Self {
357        self.gates.push(gate);
358        self
359    }
360
361    /// Append a gate in place.
362    pub fn push(&mut self, gate: Gate) {
363        self.gates.push(gate);
364    }
365
366    /// Evaluate every gate and summarise.
367    ///
368    /// All gates are inspected, so one run reports every problem rather than only the
369    /// first. The verdict names the first halting gate in declaration order.
370    pub fn evaluate(&self) -> StageReport {
371        let mut counts = GateCounts::default();
372        let mut verdict = Verdict::Proceed;
373        for gate in &self.gates {
374            counts.total += 1;
375            if gate.severity.is_blocking() {
376                counts.blocking += 1;
377            }
378            match gate.outcome {
379                Outcome::Pass => counts.passed += 1,
380                Outcome::Fail(_) => counts.failed += 1,
381                Outcome::Unevaluable(_) => counts.unevaluable += 1,
382            }
383            if verdict == Verdict::Proceed {
384                if let Some(cause) = gate.halt_cause() {
385                    verdict = Verdict::Halt {
386                        gate_id: gate.id.clone(),
387                        cause,
388                        reason: gate.outcome.reason().unwrap_or_default().to_string(),
389                    };
390                }
391            }
392        }
393        StageReport {
394            stage: self.name.clone(),
395            counts,
396            verdict,
397            gates: self.gates.clone(),
398        }
399    }
400}
401
402/// Tallies from one stage evaluation.
403#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
404pub struct GateCounts {
405    /// Gates in the stage.
406    pub total: usize,
407    /// Of those, how many are blocking.
408    pub blocking: usize,
409    /// Gates that passed.
410    pub passed: usize,
411    /// Gates that were checked and failed.
412    pub failed: usize,
413    /// Gates that could not be checked.
414    pub unevaluable: usize,
415}
416
417/// The outcome of evaluating a [`Stage`].
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct StageReport {
420    /// Name of the stage.
421    pub stage: String,
422    /// Tallies.
423    pub counts: GateCounts,
424    /// Proceed, or the first gate that halted the run.
425    pub verdict: Verdict,
426    /// Every gate as evaluated, in declaration order.
427    pub gates: Vec<Gate>,
428}
429
430impl StageReport {
431    /// Whether the stage halted the run.
432    pub fn halted(&self) -> bool {
433        self.verdict.is_halt()
434    }
435
436    /// Advisory gates that did not pass. These never halt; they are for the log.
437    pub fn warnings(&self) -> Vec<&Gate> {
438        self.gates
439            .iter()
440            .filter(|g| !g.severity.is_blocking() && !g.outcome.passed())
441            .collect()
442    }
443
444    /// Every gate that did not pass, blocking or not.
445    pub fn not_passing(&self) -> Vec<&Gate> {
446        self.gates.iter().filter(|g| !g.outcome.passed()).collect()
447    }
448}
449
450/// Ordered stages evaluated until one halts.
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct Pipeline {
453    /// The stages, in execution order.
454    pub stages: Vec<Stage>,
455}
456
457impl Pipeline {
458    /// An empty pipeline.
459    pub fn new() -> Self {
460        Pipeline { stages: Vec::new() }
461    }
462
463    /// Append a stage, builder style.
464    pub fn with_stage(mut self, stage: Stage) -> Self {
465        self.stages.push(stage);
466        self
467    }
468
469    /// Evaluate stages in order, stopping after the first that halts.
470    ///
471    /// Stages after a halt are not evaluated and do not appear in the report, which is
472    /// the honest shape: they never ran, so they have no outcome.
473    ///
474    /// ```
475    /// use gate_contract::{Gate, Pipeline, Stage};
476    ///
477    /// let run = Pipeline::new()
478    ///     .with_stage(Stage::new("intake").with_gate(Gate::pass("scope-present")))
479    ///     .with_stage(Stage::new("harvest").with_gate(Gate::unevaluable("api", "no key")))
480    ///     .with_stage(Stage::new("build").with_gate(Gate::pass("never-reached")))
481    ///     .run();
482    ///
483    /// assert!(run.halted());
484    /// assert_eq!(run.reports.len(), 2);
485    /// assert_eq!(run.halted_stage(), Some("harvest"));
486    /// ```
487    pub fn run(&self) -> RunReport {
488        let mut reports = Vec::with_capacity(self.stages.len());
489        for stage in &self.stages {
490            let report = stage.evaluate();
491            let halted = report.halted();
492            reports.push(report);
493            if halted {
494                break;
495            }
496        }
497        RunReport {
498            reports,
499            stages_declared: self.stages.len(),
500        }
501    }
502}
503
504/// The outcome of a whole pipeline run.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct RunReport {
507    /// Reports for the stages that actually ran.
508    pub reports: Vec<StageReport>,
509    /// How many stages the pipeline declared, including any never reached.
510    pub stages_declared: usize,
511}
512
513impl RunReport {
514    /// Whether any stage halted the run.
515    pub fn halted(&self) -> bool {
516        self.reports.last().map(|r| r.halted()).unwrap_or(false)
517    }
518
519    /// Name of the halting stage, if any.
520    pub fn halted_stage(&self) -> Option<&str> {
521        self.reports
522            .last()
523            .filter(|r| r.halted())
524            .map(|r| r.stage.as_str())
525    }
526
527    /// Stages declared but never evaluated because of an earlier halt.
528    pub fn stages_skipped(&self) -> usize {
529        self.stages_declared - self.reports.len()
530    }
531
532    /// Tallies summed across the stages that ran.
533    pub fn counts(&self) -> GateCounts {
534        let mut total = GateCounts::default();
535        for r in &self.reports {
536            total.total += r.counts.total;
537            total.blocking += r.counts.blocking;
538            total.passed += r.counts.passed;
539            total.failed += r.counts.failed;
540            total.unevaluable += r.counts.unevaluable;
541        }
542        total
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn a_passing_blocking_gate_proceeds() {
552        let report = Stage::new("intake").with_gate(Gate::pass("scope")).evaluate();
553        assert_eq!(report.verdict, Verdict::Proceed);
554        assert!(!report.halted());
555        assert_eq!(report.counts.passed, 1);
556    }
557
558    #[test]
559    fn a_failing_blocking_gate_halts() {
560        let report = Stage::new("build")
561            .with_gate(Gate::fail("pages-nonzero", "zero pages"))
562            .evaluate();
563        match report.verdict {
564            Verdict::Halt {
565                gate_id,
566                cause,
567                reason,
568            } => {
569                assert_eq!(gate_id, "pages-nonzero");
570                assert_eq!(cause, HaltCause::BlockingFailure);
571                assert_eq!(reason, "zero pages");
572            }
573            Verdict::Proceed => panic!("should have halted"),
574        }
575    }
576
577    #[test]
578    fn an_unevaluable_blocking_gate_also_halts() {
579        let report = Stage::new("harvest")
580            .with_gate(Gate::unevaluable("rdap", "no credential"))
581            .evaluate();
582        assert!(report.halted());
583        assert!(matches!(
584            report.verdict,
585            Verdict::Halt {
586                cause: HaltCause::BlockingUnevaluable,
587                ..
588            }
589        ));
590        assert_eq!(report.counts.unevaluable, 1);
591    }
592
593    #[test]
594    fn advisory_gates_never_halt_but_are_recorded() {
595        let report = Stage::new("design")
596            .with_gate(Gate::fail("tone", "flagged").advisory())
597            .with_gate(Gate::unevaluable("readability", "no corpus").advisory())
598            .with_gate(Gate::pass("prompt-vendored"))
599            .evaluate();
600        assert!(!report.halted());
601        assert_eq!(report.warnings().len(), 2);
602        assert_eq!(report.not_passing().len(), 2);
603        assert_eq!(report.counts.blocking, 1);
604        assert_eq!(report.counts.total, 3);
605    }
606
607    #[test]
608    fn the_first_halting_gate_is_the_one_reported() {
609        let report = Stage::new("qualify")
610            .with_gate(Gate::pass("a"))
611            .with_gate(Gate::fail("b", "first"))
612            .with_gate(Gate::unevaluable("c", "second"))
613            .evaluate();
614        match report.verdict {
615            Verdict::Halt { gate_id, .. } => assert_eq!(gate_id, "b"),
616            Verdict::Proceed => panic!("should have halted"),
617        }
618        // Evaluation still visits every gate.
619        assert_eq!(report.counts.failed, 1);
620        assert_eq!(report.counts.unevaluable, 1);
621    }
622
623    #[test]
624    fn check_builds_pass_or_fail_from_a_boolean() {
625        assert!(Gate::check("g", true, "r").outcome.passed());
626        assert_eq!(
627            Gate::check("g", false, "r").outcome,
628            Outcome::Fail("r".into())
629        );
630    }
631
632    #[test]
633    fn unknown_input_makes_a_gate_unevaluable_not_passing() {
634        let unknown: Known<u32> = Known::Unknown;
635        let gate = Gate::check_known("coverage", &unknown, |c| *c >= 50, "below floor");
636        assert!(!gate.outcome.was_evaluated());
637        assert!(gate.halts());
638
639        let known = Known::Known(60u32);
640        let gate = Gate::check_known("coverage", &known, |c| *c >= 50, "below floor");
641        assert!(gate.outcome.passed());
642        assert!(!gate.halts());
643    }
644
645    #[test]
646    fn unknown_propagates_through_combination() {
647        let a = Known::Known(10);
648        let b: Known<i32> = Known::Unknown;
649        assert_eq!(a.zip_with(b, |x, y| x + y), Known::Unknown);
650        assert_eq!(
651            Known::Known(10).zip_with(Known::Known(5), |x, y| x + y),
652            Known::Known(15)
653        );
654        assert_eq!(b.map(|v| v * 2), Known::Unknown);
655        assert_eq!(Known::from(Some(3)), Known::Known(3));
656        assert_eq!(Known::<i32>::from(None), Known::Unknown);
657        assert_eq!(Known::<i32>::default(), Known::Unknown);
658        assert_eq!(Known::Known(7).get(), Some(&7));
659        assert!(!b.is_known());
660    }
661
662    #[test]
663    fn pipeline_stops_at_the_first_halting_stage() {
664        let run = Pipeline::new()
665            .with_stage(Stage::new("intake").with_gate(Gate::pass("scope")))
666            .with_stage(Stage::new("harvest").with_gate(Gate::unevaluable("api", "no key")))
667            .with_stage(Stage::new("build").with_gate(Gate::pass("unreached")))
668            .run();
669        assert!(run.halted());
670        assert_eq!(run.halted_stage(), Some("harvest"));
671        assert_eq!(run.reports.len(), 2);
672        assert_eq!(run.stages_skipped(), 1);
673        assert_eq!(run.counts().total, 2);
674    }
675
676    #[test]
677    fn a_clean_pipeline_proceeds_through_every_stage() {
678        let run = Pipeline::new()
679            .with_stage(Stage::new("one").with_gate(Gate::pass("a")))
680            .with_stage(Stage::new("two").with_gate(Gate::pass("b")))
681            .run();
682        assert!(!run.halted());
683        assert_eq!(run.halted_stage(), None);
684        assert_eq!(run.stages_skipped(), 0);
685        assert_eq!(run.counts().passed, 2);
686    }
687
688    #[test]
689    fn an_empty_pipeline_does_not_claim_to_have_halted() {
690        let run = Pipeline::new().run();
691        assert!(!run.halted());
692        assert_eq!(run.counts(), GateCounts::default());
693    }
694
695    #[test]
696    fn a_stage_can_be_built_imperatively() {
697        let mut stage = Stage::new("measure");
698        stage.push(Gate::pass("gsc-rows"));
699        stage.push(Gate::fail("indexed", "0 of 14").advisory());
700        let report = stage.evaluate();
701        assert_eq!(report.counts.total, 2);
702        assert!(!report.halted());
703    }
704
705    #[test]
706    fn display_is_readable() {
707        assert_eq!(Outcome::Pass.to_string(), "PASS");
708        assert_eq!(
709            Gate::fail("x", "because").to_string(),
710            "[blocking] x FAIL: because"
711        );
712        assert_eq!(
713            HaltCause::BlockingUnevaluable.to_string(),
714            "blocking gate could not be evaluated"
715        );
716    }
717
718    #[test]
719    fn outcome_reasons_are_retrievable() {
720        assert_eq!(Outcome::Pass.reason(), None);
721        assert_eq!(Outcome::Fail("r".into()).reason(), Some("r"));
722        assert_eq!(Outcome::Unevaluable("u".into()).reason(), Some("u"));
723        assert!(Severity::Blocking.is_blocking());
724        assert!(!Severity::Advisory.is_blocking());
725    }
726}