fallow_output/request_outcomes.rs
1//! What happened to every narrowing or shaping request a run received.
2//!
3//! One shape for every command that can be asked to scope or shape its report,
4//! so a consumer reads the same members whether the envelope came from
5//! `dead-code`, `dupes`, `health` or `security`. Carried as `request_outcomes`
6//! at the envelope root, absent whenever the run was asked for nothing.
7//!
8//! This is the mirror image of [`crate::GateOutcomes`]. That object answers
9//! "what did the run conclude"; this one answers "did the run do what it was
10//! asked". Both are keyed maps at the root with an open key set, so a consumer
11//! learns one reading rule for both, and a request added in a later release
12//! reaches an unchanged consumer.
13//!
14//! # Why the honoured case is published too
15//!
16//! An entry appears for every request the run RECEIVED, including the ones it
17//! honoured, exactly as `gate_outcomes` publishes gates that passed. A report
18//! that says "scoped to the change" positively is the reviewer question this
19//! object is actually about, and a reader who only ever sees failures cannot
20//! tell "the filter applied" from "nothing was asked for".
21//!
22//! # Why this is not a `workspace_diagnostics` entry
23//!
24//! `WorkspaceDiagnostic` requires a `path` naming the file or directory that
25//! triggered it, and an unresolvable git ref or an oversize diff has no such
26//! path. More decisively, `degrades_analysis` means "less reached the analysis
27//! than the user expected", and the shipped consumer sentence built from it
28//! says findings were computed over less than the whole project. These facts
29//! mean the opposite: MORE was reported than was asked for. Routing them
30//! through that array would make an existing warning state something false.
31//!
32//! # Why it carries prose when `gate_outcomes` deliberately does not
33//!
34//! A gate verdict is rendered from `status`, `observed` and `threshold`, so it
35//! needs no sentence. Here every reason carries a different remedy (fetch the
36//! ref, regenerate the diff from the repository root, check out with full
37//! history), the CLI already writes those sentences to stderr, and a rendered
38//! pull-request comment needs one of them in its body. Reproducing nine
39//! remedies in bash and in jq, twice, is how they drift.
40//! `WorkspaceDiagnostic::message` is the precedent.
41//!
42//! # Wire compatibility
43//!
44//! The key set and the `status` value set are both OPEN. A name or a status
45//! this build does not recognise means "some request" and "some outcome", not
46//! an error, the same tolerate-unknown-values contract
47//! `workspace_diagnostics[].kind` documents. The Rust types are closed enums so
48//! the emitter cannot drift, and a request added later is an additive optional
49//! key that bumps no `schema_version`.
50
51use std::collections::BTreeMap;
52
53use serde::Serialize;
54
55/// Which request an outcome describes.
56///
57/// Taken from the flags and environment channels a run can be narrowed or
58/// shaped by, rather than from any integration's input list, because a request
59/// a consumer cannot name is exactly the one whose fate goes missing.
60/// Serialized as kebab-case and published as an open set.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
63#[serde(rename_all = "kebab-case")]
64pub enum RequestName {
65 /// `--changed-since <ref>`: scope the analysis to the files that changed
66 /// since a git ref. Not applied means the report covers the whole project.
67 ChangedSince,
68 /// `--diff-file`, `--diff-stdin` or `$FALLOW_DIFF_FILE`: keep only the
69 /// findings a supplied unified diff touches. Not applied means every
70 /// finding is reported.
71 DiffFilter,
72 /// `workspaces.changedSince` in the config: scope the findings of each
73 /// mapped workspace package to the files that changed since its own ref.
74 /// Not applied means a key named no workspace or a mapped ref did not
75 /// resolve, so the report covers every package in full scope. A global changed-since ref overrides the
76 /// map, and the run then publishes no entry for it.
77 PackageBaselines,
78 /// `--sarif-file <path>`: also write the findings as a SARIF document at
79 /// `path`. Not applied means the file was never written, so a consumer
80 /// uploading it to code scanning has nothing to upload. The primary report
81 /// on stdout is unaffected, which is why the run neither fails nor says
82 /// anything else about it.
83 SarifFile,
84 /// `--group <KEY>` with `--group-by`: keep only the matching groups in the
85 /// `groups` array. `scope_size` counts the groups the run kept, so `0`
86 /// means that no group matched. The project-level sections are not
87 /// filtered.
88 GroupFilter,
89}
90
91impl RequestName {
92 /// What an unapplied request of this name means for the report.
93 #[must_use]
94 pub const fn affects(self) -> RequestEffect {
95 match self {
96 Self::ChangedSince | Self::DiffFilter | Self::PackageBaselines | Self::GroupFilter => {
97 RequestEffect::Scope
98 }
99 Self::SarifFile => RequestEffect::Artifact,
100 }
101 }
102}
103
104/// What a request governs, and therefore what its failure means.
105///
106/// Published on every entry so a consumer selects on the class rather than on
107/// a name list. Without it the one sentence a consumer can write for the whole
108/// object ("the report is wider than requested") is false for any request that
109/// does not narrow, which is how a failed `--sarif-file` write came to be
110/// reported as an unscoped run. A request name added later carries its own
111/// class, so a consumer written today keeps saying the right thing about it.
112///
113/// The value set is OPEN, like the names and the statuses: read a class this
114/// build does not recognise as "some request", not as an error, and do not read
115/// it as `scope`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[serde(rename_all = "kebab-case")]
119pub enum RequestEffect {
120 /// The request narrows what the report covers. Not applied means the
121 /// report that follows is complete, valid, and WIDER than what was asked
122 /// for, which is a reviewing problem rather than a build failure.
123 Scope,
124 /// The request produces a secondary file beside the report. Not applied
125 /// means that file was not written, so anything consuming it has nothing to
126 /// read. The report on stdout and the exit code are unaffected, and nothing
127 /// about the run's scope changed.
128 Artifact,
129}
130
131/// What became of one request on this run.
132///
133/// Two-valued today. The value set is OPEN so a later `partial` needs no bump,
134/// and it is deliberately not added now: nothing emits it, and a permanently
135/// unused value reads as a measurement nobody takes.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138#[serde(rename_all = "kebab-case")]
139pub enum RequestStatus {
140 /// The run did what it was asked. The report is scoped or shaped as
141 /// requested.
142 Applied,
143 /// The run could not do what it was asked and continued anyway. The report
144 /// that follows is valid and complete; what the failure cost is read off
145 /// `affects`, which says whether the report widened or a requested file was
146 /// never written.
147 NotApplied,
148}
149
150/// One request's fate on one run.
151///
152/// `reason` and `message` are present exactly when `status` is not `applied`,
153/// and absent otherwise, so a consumer that only wants to know whether a
154/// report is scoped reads `status` alone.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub struct RequestOutcome {
158 /// What became of the request.
159 pub status: RequestStatus,
160 /// What this request governs, and therefore what an unapplied one means.
161 /// Derived from the name, so the two can never disagree.
162 pub affects: RequestEffect,
163 /// What was asked, as the user spelled it: the git ref for
164 /// `changed-since`, the diff source label (`--diff-file pr.diff`,
165 /// `--diff-stdin`, `$FALLOW_DIFF_FILE build/pr.diff`, or
166 /// `diffFile pr.diff` for the programmatic option) for `diff-filter`,
167 /// `workspaces.changedSince` for `package-baselines`, the target path for
168 /// `sarif-file`, the comma-joined selector patterns for `group-filter`.
169 /// Echoed rather than normalised, so a
170 /// consumer must not join it to the project root the way it joins every
171 /// other path-shaped field.
172 pub requested: String,
173 /// How much this request left in scope, in the request's own unit, when the
174 /// run applied it AND measured that scope. Absent otherwise, including on
175 /// every unapplied entry: a request that stood down narrowed nothing, so a
176 /// number there would describe a scope nobody applied.
177 ///
178 /// The unit belongs to the name. `diff-filter` counts added lines, which is
179 /// what its filter keeps a finding for. `changed-since` counts changed
180 /// files that the run analyzed: a changed file that discovery or an ignore
181 /// rule dropped does not count, so a change to a README only gives `0`. A
182 /// combined run counts a file that any of its analyses kept, because a
183 /// per-analysis `production` setting can give its analyses different
184 /// files. `group-filter` counts the groups that the run kept.
185 /// Read the unit off the name the entry is keyed under, never across names,
186 /// and read an absent member as "not measured" rather than as zero.
187 ///
188 /// The count is what the run INDEXED rather than the true total:
189 /// `diff-filter` indexes at most one million added lines and reports that
190 /// cap for a larger diff, so read any non-zero value as a lower bound.
191 ///
192 /// `0` is the case this member exists for: a request that applied over an
193 /// EMPTY scope. Every finding then filters out and the report reads clean,
194 /// so a consumer that sees no findings beside `scope_size: 0` learns that
195 /// nothing was analyzable rather than that the code is clean.
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub scope_size: Option<u64>,
198 /// Why the request was not applied, as a kebab-case token. Present exactly
199 /// when `status` is not `applied`. The set is open per request name; the
200 /// names this build can emit are listed on [`RequestOutcomes`].
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub reason: Option<String>,
203 /// One sentence naming what was asked, what happened instead, and the next
204 /// step. Byte-identical to the stderr line for the same case, so a
205 /// consumer that renders this never contradicts a log a human read.
206 /// Present exactly when `status` is not `applied`.
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub message: Option<String>,
209}
210
211impl RequestOutcome {
212 /// A request the run honoured.
213 ///
214 /// Takes the name rather than the class so no caller can file a request
215 /// under the wrong one.
216 #[must_use]
217 pub fn applied(name: RequestName, requested: impl Into<String>) -> Self {
218 Self {
219 status: RequestStatus::Applied,
220 affects: name.affects(),
221 requested: requested.into(),
222 scope_size: None,
223 reason: None,
224 message: None,
225 }
226 }
227
228 /// A request the run honoured, whose remaining scope it also measured.
229 ///
230 /// `size` is in the unit [`RequestOutcome::scope_size`] documents for this
231 /// name. Use [`Self::applied`] where the run applies a request without
232 /// measuring what it left, so the member stays absent rather than claiming
233 /// a zero nobody counted.
234 #[must_use]
235 pub fn applied_with_scope_size(
236 name: RequestName,
237 requested: impl Into<String>,
238 size: u64,
239 ) -> Self {
240 Self {
241 scope_size: Some(size),
242 ..Self::applied(name, requested)
243 }
244 }
245
246 /// A request the run could not honour, with the reason token and the
247 /// sentence the CLI also wrote to stderr.
248 #[must_use]
249 pub fn not_applied(
250 name: RequestName,
251 requested: impl Into<String>,
252 reason: impl Into<String>,
253 message: impl Into<String>,
254 ) -> Self {
255 Self {
256 status: RequestStatus::NotApplied,
257 affects: name.affects(),
258 requested: requested.into(),
259 scope_size: None,
260 reason: Some(reason.into()),
261 message: Some(message.into()),
262 }
263 }
264}
265
266/// Every narrowing or shaping request a run RECEIVED, keyed by name.
267///
268/// Received, not failed: a request the run honoured is published with
269/// `status: "applied"`, so a consumer can say "scoped to the change"
270/// positively. Read an absent object as "nothing was asked for", never as
271/// "nothing failed".
272///
273/// Absent from an envelope whenever it is empty, so a run that was asked for
274/// nothing is byte-identical to one produced before this object existed. An
275/// empty object is never emitted: it would assert that something was asked and
276/// all of it applied, which is a different and false claim.
277///
278/// The names this build can emit are `changed-since`, `diff-filter`,
279/// `package-baselines`, `sarif-file` and `group-filter`. The reasons are `git-missing`,
280/// `not-a-repository`, `git-failed` and `invalid-ref` for `changed-since`,
281/// `unknown-workspace`, `git-missing`, `not-a-repository` and `git-failed`
282/// for `package-baselines`, `oversize`,
283/// `unreadable`, `not-utf8`, `foreign-namespace` and `ambiguous-base` for
284/// `diff-filter`, and `directory-create-failed`, `write-failed` and
285/// `serialize-failed` for `sarif-file`. Every set is OPEN: a name a consumer
286/// does not recognise means "some request", not an error.
287///
288/// `sarif-file` reports a SECONDARY artifact rather than the scope of the
289/// report it travels in, and it is in the same object for the same reason the
290/// others are: the run was asked to do something and did something else, and
291/// nothing in the primary report says so. Which of the two an entry is, every
292/// entry says for itself: `affects` is `scope` for the narrowing requests and
293/// `artifact` for this one. Select on it. A consumer that instead assumes the
294/// whole object narrows the report tells its reader an unwritten SARIF file
295/// widened the analysis, which is what `affects` exists to prevent.
296///
297/// `scope_size` is emitted for `diff-filter`, in added lines, for
298/// `changed-since`, in changed files that the run analyzed, and for
299/// `group-filter`, in groups that the run kept. `package-baselines`
300/// and `sarif-file` measure no scope; the applied package refs travel in
301/// `package_baselines`. A consumer reads the unit off the name, so a name that
302/// starts to measure its own scope in a later release needs no change here.
303///
304/// `invalid-ref` is reachable only through the programmatic API. The
305/// `--changed-since` flag validates its value before a run starts and fails
306/// with exit 2 and an error document, which is the right side to err on: a
307/// malformed ref is invalid input rather than a report of the wrong scope.
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[serde(transparent)]
311pub struct RequestOutcomes(BTreeMap<RequestName, RequestOutcome>);
312
313impl RequestOutcomes {
314 /// An empty set, which serializes to nothing once [`Self::into_option`]
315 /// has been applied.
316 #[must_use]
317 pub fn new() -> Self {
318 Self(BTreeMap::new())
319 }
320
321 /// Record one request's outcome, replacing any previous entry for that
322 /// name.
323 pub fn insert(&mut self, name: RequestName, outcome: RequestOutcome) {
324 self.0.insert(name, outcome);
325 }
326
327 /// Record one request's outcome when the run received it at all.
328 pub fn insert_if(&mut self, name: RequestName, outcome: Option<RequestOutcome>) {
329 if let Some(outcome) = outcome {
330 self.0.insert(name, outcome);
331 }
332 }
333
334 /// Collapse an empty set to `None`, which is how the field stays absent on
335 /// a run that was asked for nothing.
336 #[must_use]
337 pub fn into_option(self) -> Option<Self> {
338 if self.0.is_empty() { None } else { Some(self) }
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn empty_set_collapses_to_absent() {
348 assert!(RequestOutcomes::new().into_option().is_none());
349 }
350
351 #[test]
352 fn populated_set_survives_collapse() {
353 let mut requests = RequestOutcomes::new();
354 requests.insert(
355 RequestName::ChangedSince,
356 RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
357 );
358 assert!(requests.into_option().is_some());
359 }
360
361 /// An honoured request carries neither a reason nor a sentence, so a
362 /// consumer reading `message` never renders prose about a run that did
363 /// exactly what it was told.
364 #[test]
365 fn an_applied_request_carries_no_reason_and_no_message() {
366 let mut requests = RequestOutcomes::new();
367 requests.insert(
368 RequestName::DiffFilter,
369 RequestOutcome::applied(RequestName::DiffFilter, "--diff-file pr.diff"),
370 );
371 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
372 assert_eq!(
373 value,
374 serde_json::json!({
375 "diff-filter": {
376 "status": "applied",
377 "affects": "scope",
378 "requested": "--diff-file pr.diff"
379 }
380 })
381 );
382 }
383
384 #[test]
385 fn names_and_statuses_serialize_as_kebab_case() {
386 let mut requests = RequestOutcomes::new();
387 requests.insert(
388 RequestName::ChangedSince,
389 RequestOutcome::not_applied(
390 RequestName::ChangedSince,
391 "origin/main",
392 "invalid-ref",
393 "Ignored.",
394 ),
395 );
396 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
397 assert_eq!(
398 value,
399 serde_json::json!({
400 "changed-since": {
401 "status": "not-applied",
402 "affects": "scope",
403 "requested": "origin/main",
404 "reason": "invalid-ref",
405 "message": "Ignored."
406 }
407 })
408 );
409 }
410
411 /// Two requests with different fates travel in one object, so a consumer
412 /// reads the scope of each channel rather than one verdict for the run.
413 #[test]
414 fn two_channels_keep_their_own_status_in_one_object() {
415 let mut requests = RequestOutcomes::new();
416 requests.insert(
417 RequestName::ChangedSince,
418 RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
419 );
420 requests.insert(
421 RequestName::DiffFilter,
422 RequestOutcome::not_applied(
423 RequestName::DiffFilter,
424 "--diff-stdin",
425 "not-utf8",
426 "Ignored.",
427 ),
428 );
429 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
430 assert_eq!(value["changed-since"]["status"], "applied");
431 assert_eq!(value["diff-filter"]["status"], "not-applied");
432 assert_eq!(value["diff-filter"]["reason"], "not-utf8");
433 }
434
435 /// The class travels with the entry, so a consumer never has to keep a
436 /// name list to know whether an unapplied request widened the report.
437 #[test]
438 fn a_secondary_artifact_request_is_not_classed_as_scope() {
439 let mut requests = RequestOutcomes::new();
440 requests.insert(
441 RequestName::SarifFile,
442 RequestOutcome::not_applied(
443 RequestName::SarifFile,
444 "out.sarif",
445 "write-failed",
446 "Not written.",
447 ),
448 );
449 requests.insert(
450 RequestName::ChangedSince,
451 RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
452 );
453 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
454 assert_eq!(value["sarif-file"]["affects"], "artifact");
455 assert_eq!(value["changed-since"]["affects"], "scope");
456 }
457
458 /// The class is derived from the name at construction, so an entry filed
459 /// under one name cannot carry another's class.
460 #[test]
461 fn every_name_carries_its_own_class() {
462 assert_eq!(RequestName::ChangedSince.affects(), RequestEffect::Scope);
463 assert_eq!(RequestName::DiffFilter.affects(), RequestEffect::Scope);
464 assert_eq!(RequestName::SarifFile.affects(), RequestEffect::Artifact);
465 }
466
467 /// An applied request that measured an empty scope is the case the member
468 /// exists for: `status` stays `applied`, because the filter DID apply, and
469 /// the zero is what tells a consumer the clean report covered nothing.
470 #[test]
471 fn an_empty_measured_scope_stays_applied_and_publishes_its_zero() {
472 let mut requests = RequestOutcomes::new();
473 requests.insert(
474 RequestName::DiffFilter,
475 RequestOutcome::applied_with_scope_size(
476 RequestName::DiffFilter,
477 "--diff-file pr.diff",
478 0,
479 ),
480 );
481 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
482 assert_eq!(
483 value,
484 serde_json::json!({
485 "diff-filter": {
486 "status": "applied",
487 "affects": "scope",
488 "requested": "--diff-file pr.diff",
489 "scope_size": 0
490 }
491 })
492 );
493 }
494
495 /// Absent is not zero. A request the run applied without counting what it
496 /// left carries no member, so a consumer cannot read "not measured" as "the
497 /// scope was empty".
498 #[test]
499 fn a_request_that_measured_nothing_carries_no_scope_size() {
500 let mut requests = RequestOutcomes::new();
501 requests.insert(
502 RequestName::ChangedSince,
503 RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
504 );
505 requests.insert(
506 RequestName::DiffFilter,
507 RequestOutcome::not_applied(
508 RequestName::DiffFilter,
509 "--diff-stdin",
510 "oversize",
511 "Ignored.",
512 ),
513 );
514 let value = serde_json::to_value(&requests).expect("request outcomes serialize");
515 assert!(
516 value["changed-since"].get("scope_size").is_none(),
517 "an unmeasured applied request carries no member: {value}"
518 );
519 assert!(
520 value["diff-filter"].get("scope_size").is_none(),
521 "a request that stood down narrowed nothing: {value}"
522 );
523 }
524
525 #[test]
526 fn insert_if_skips_a_request_the_run_never_received() {
527 let mut requests = RequestOutcomes::new();
528 requests.insert_if(RequestName::ChangedSince, None);
529 assert!(requests.into_option().is_none());
530 }
531}