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}