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