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