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