fallow_output/baseline_staleness.rs
1//! The machine-readable view of a loaded baseline's staleness.
2//!
3//! One shape for every command that accepts `--baseline`, so a consumer reads
4//! the same member names whether the envelope came from `dead-code`, `dupes` or
5//! `health`. Carried as `baseline_staleness` at the dead-code and duplication
6//! roots and inside `summary` on health, absent whenever no baseline was loaded.
7//!
8//! Every member is a projection of the run's
9//! `fallow_engine::baseline::BaselineStaleness`, so nothing here restates a rule
10//! that lives in the engine. `gate_trips` in particular is computed by the same
11//! function the `--fail-on-stale-baseline` exit gate calls, which is why a CI
12//! integration can read one boolean instead of reimplementing the condition in
13//! jq.
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16
17/// One channel that narrowed a run to part of the project.
18///
19/// Serialized as kebab-case inside `scope_reasons` and published as an OPEN
20/// set, the same tolerate-unknown contract `gate_outcomes` keys carry: a name
21/// this build does not emit means "some narrowing", not an error.
22///
23/// Which names a command can emit differs per command, because the three
24/// narrowing predicates see different state. `dead-code` reads the flags
25/// themselves and can name every channel. `dupes` and `health` see an already
26/// resolved changed-file set and report `changed-files`, because at that point
27/// the flag that produced it is gone. `health` reports `workspace` for both
28/// `--workspace` and `--changed-workspaces` for the same reason. A consumer
29/// must therefore not assume a given command emits a given name.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(rename_all = "kebab-case")]
33pub enum ScopeReason {
34 /// A diff index reached the analysis, from `--diff-file`, `--diff-stdin`,
35 /// `FALLOW_DIFF_FILE` or the shared index a CI format installs.
36 Diff,
37 /// `--changed-since`.
38 ChangedSince,
39 /// The per-package refs of `workspaces.changedSince` in the config.
40 /// `--no-package-baselines` turns them off for one run.
41 PackageBaselines,
42 /// A resolved changed-file set, on the commands that see the set rather
43 /// than the flag that produced it.
44 ChangedFiles,
45 /// `--workspace`. On `health` this also covers `--changed-workspaces`,
46 /// which it cannot distinguish.
47 Workspace,
48 /// `--changed-workspaces`.
49 ChangedWorkspaces,
50 /// `--scope`.
51 Scope,
52 /// One or more `--file`.
53 File,
54 /// An active issue-type filter such as `--unused-exports`, which drops
55 /// whole baseline categories before the comparison.
56 IssueTypeFilter,
57 /// Production mode, from the flag or the resolved project config. It drops
58 /// test, story and dev files at discovery.
59 Production,
60 /// `--include-entry-exports` or the `includeEntryExports` config key. It
61 /// changes which exports `unused-exports` reports, so the run judges a
62 /// different export surface than a run without it.
63 IncludeEntryExports,
64}
65
66impl ScopeReason {
67 /// Every reason, in the declaration order `scope_reasons` serializes in.
68 const ALL: [Self; 11] = [
69 Self::Diff,
70 Self::ChangedSince,
71 Self::PackageBaselines,
72 Self::ChangedFiles,
73 Self::Workspace,
74 Self::ChangedWorkspaces,
75 Self::Scope,
76 Self::File,
77 Self::IssueTypeFilter,
78 Self::Production,
79 Self::IncludeEntryExports,
80 ];
81
82 /// The kebab-case name this reason serializes as, for prose that has to
83 /// name it outside the JSON envelope.
84 #[must_use]
85 pub const fn as_str(self) -> &'static str {
86 match self {
87 Self::Diff => "diff",
88 Self::ChangedSince => "changed-since",
89 Self::PackageBaselines => "package-baselines",
90 Self::ChangedFiles => "changed-files",
91 Self::Workspace => "workspace",
92 Self::ChangedWorkspaces => "changed-workspaces",
93 Self::Scope => "scope",
94 Self::File => "file",
95 Self::IssueTypeFilter => "issue-type-filter",
96 Self::Production => "production",
97 Self::IncludeEntryExports => "include-entry-exports",
98 }
99 }
100
101 /// Whether repeating the run without this channel judges the same project.
102 ///
103 /// A channel the caller added for one run, a diff, a base ref, a path or an
104 /// issue-type filter, is removable: dropping it widens the run to the whole
105 /// project, which is exactly what judging a whole-project baseline needs.
106 /// Production mode and workspace scoping are the caller's own statement
107 /// about what the project is, and they resolve from the project config and
108 /// the environment as well as from a flag, so repeating the command without
109 /// the flag analyzes something nobody asked about and, on the config and
110 /// environment routes, is not even narrower. The package map is the one
111 /// config channel that is removable: `--no-package-baselines` turns it off
112 /// for the repeated run.
113 ///
114 /// This is the rule the GitHub Action and the GitLab template already apply
115 /// before re-reading a baseline unscoped, and the one the `scope_reasons`
116 /// documentation states.
117 #[must_use]
118 pub const fn is_removable_by_rerun(self) -> bool {
119 match self {
120 Self::Diff
121 | Self::ChangedSince
122 | Self::PackageBaselines
123 | Self::ChangedFiles
124 | Self::Scope
125 | Self::File
126 | Self::IssueTypeFilter => true,
127 Self::Workspace
128 | Self::ChangedWorkspaces
129 | Self::Production
130 | Self::IncludeEntryExports => false,
131 }
132 }
133
134 const fn bit(self) -> u16 {
135 1 << (self as u16)
136 }
137}
138
139/// The set of channels that narrowed one run.
140///
141/// A bitset rather than a `Vec` so [`BaselineStaleness`] keeps `Copy`, which
142/// the gate builder's `const fn` and three envelope structs that hold the
143/// object by value rely on. Serializes as an array of [`ScopeReason`] in
144/// declaration order, so two identical runs produce identical bytes.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146pub struct BaselineScopeReasons(u16);
147
148/// The bitset holds one bit per reason, so a seventeenth variant would alias
149/// the first in release builds.
150const _: () = assert!(ScopeReason::ALL.len() <= u16::BITS as usize);
151
152impl BaselineScopeReasons {
153 /// A run that was not narrowed.
154 #[must_use]
155 pub const fn empty() -> Self {
156 Self(0)
157 }
158
159 /// This set plus `reason`.
160 #[must_use]
161 pub const fn with(self, reason: ScopeReason) -> Self {
162 Self(self.0 | reason.bit())
163 }
164
165 /// This set plus `reason` when `active`, unchanged otherwise.
166 #[must_use]
167 pub const fn insert_if(self, active: bool, reason: ScopeReason) -> Self {
168 if active { self.with(reason) } else { self }
169 }
170
171 /// True when nothing narrowed the run, which is exactly when
172 /// `change_scoped` is false.
173 #[must_use]
174 pub const fn is_empty(&self) -> bool {
175 self.0 == 0
176 }
177
178 /// Whether `reason` narrowed the run.
179 #[must_use]
180 pub const fn contains(self, reason: ScopeReason) -> bool {
181 self.0 & reason.bit() != 0
182 }
183
184 /// The reasons in wire order.
185 pub fn iter(self) -> impl Iterator<Item = ScopeReason> {
186 ScopeReason::ALL
187 .into_iter()
188 .filter(move |reason| self.contains(*reason))
189 }
190
191 /// True when repeating the run without every channel in this set judges
192 /// the same project, so a command that drops them all is worth suggesting.
193 ///
194 /// Vacuously true for an empty set; callers that mean "this run was
195 /// narrowed and can be widened" check [`Self::is_empty`] first.
196 #[must_use]
197 pub fn all_removable_by_rerun(self) -> bool {
198 self.iter().all(ScopeReason::is_removable_by_rerun)
199 }
200
201 /// The reasons as a comma-joined list of kebab-case names, for prose.
202 /// Empty when the run was not narrowed.
203 #[must_use]
204 pub fn join(self) -> String {
205 self.iter()
206 .map(ScopeReason::as_str)
207 .collect::<Vec<_>>()
208 .join(", ")
209 }
210}
211
212impl Serialize for BaselineScopeReasons {
213 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
214 serializer.collect_seq(self.iter())
215 }
216}
217
218impl<'de> Deserialize<'de> for BaselineScopeReasons {
219 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220 let reasons = Vec::<ScopeReason>::deserialize(deserializer)?;
221 Ok(reasons
222 .into_iter()
223 .fold(Self::empty(), |set, reason| set.with(reason)))
224 }
225}
226
227/// Which advisory a loaded baseline earned on this run.
228///
229/// Mirrors `fallow_engine::baseline::BaselineStalenessWarning` so a consumer can
230/// render the same distinction the stderr warning makes, instead of inferring it
231/// from counts.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234#[serde(rename_all = "kebab-case")]
235pub enum BaselineStalenessAdvisory {
236 /// Nothing to say: the baseline is fresh enough, or this run cannot judge
237 /// it (a narrowed scope, an empty baseline, or a run with no findings to
238 /// match against).
239 None,
240 /// Nothing in the baseline matched and there were findings to match, so the
241 /// paths likely moved or the baseline was saved elsewhere.
242 ZeroOverlap,
243 /// A quarter or more of the baseline matched nothing, so it protects
244 /// meaningfully less than what was saved.
245 Partial,
246}
247
248/// One run's machine-readable view of a loaded baseline.
249///
250/// `stale` and `gate_trips` answer different questions and legitimately
251/// disagree. `stale` mirrors the unasked-for stderr advisory, which stays silent
252/// below a quarter of the baseline and on a run that produced no findings at
253/// all, because a cleaned project and a rotted baseline look identical from
254/// there. `gate_trips` mirrors the opt-in `--fail-on-stale-baseline` rule, which
255/// a repository asks for precisely to catch those cases, so it fires on any
256/// stale entry. A rotted baseline on a cleaned project reports
257/// `stale: false` with `gate_trips: true`; that is the contract, not a defect.
258///
259/// `change_scoped` is the member a consumer must read before dividing
260/// `matched_entries` by `baseline_entries`. A run narrowed to part of the
261/// project compares a whole-project baseline against a slice of it and can
262/// report `matched_entries: 0` while the baseline is perfectly healthy, so both
263/// `stale` and `gate_trips` are false there by construction. The remedy for a
264/// tripped gate is always the same: re-save the baseline from a whole-project
265/// run with `--save-baseline`.
266#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268pub struct BaselineStaleness {
269 /// Entries carried by the loaded baseline file. On health these are the
270 /// complexity and CRAP finding entries; runtime-coverage suppressions and
271 /// refactoring target keys carried by the same file are not counted.
272 pub baseline_entries: usize,
273 /// Entries that matched a current finding on this run and were filtered out
274 /// of the report. On health this includes entries matched through a
275 /// followed file move.
276 pub matched_entries: usize,
277 /// Entries that matched no current finding on this run:
278 /// `baseline_entries - matched_entries`.
279 pub stale_entries: usize,
280 /// Findings this run produced before the baseline filtered them. Zero means
281 /// there was nothing to compare, either because the project is clean or
282 /// because the scope was empty, which is why `stale` stays false there even
283 /// when every entry went unmatched.
284 pub current_findings: usize,
285 /// Health only: the number of functions above a complexity threshold that
286 /// the baseline does not accept. This is a count of functions, like
287 /// `summary.functions_above_threshold`, not a count of baseline entries,
288 /// and it is the value before `--top`. A run that does not list the
289 /// complexity findings (for example `--score`) still reports it, so a
290 /// reader can tell the new functions from the accepted ones.
291 ///
292 /// `dead-code` and `dupes` do not emit it. An envelope from a fallow
293 /// version before this member does not carry it either.
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub remaining_findings: Option<usize>,
296 /// True when this run analyzed only part of the project, so a whole-project
297 /// baseline matches less of it for reasons that are not rot. The channels
298 /// differ per command and include a diff, a base ref, `--changed-since`,
299 /// `--workspace`, `--changed-workspaces`, `--scope`, `--file`, an
300 /// issue-type filter, and production mode. Both `stale` and `gate_trips`
301 /// are false whenever this is true. `scope_reasons` names the channels
302 /// that fired.
303 pub change_scoped: bool,
304 /// True exactly when the advisory stderr warning fired: not change-scoped,
305 /// at least one current finding before baseline filtering, and either
306 /// nothing matched or `stale_entries` reached a quarter of
307 /// `baseline_entries`.
308 pub stale: bool,
309 /// Which advisory this run earned, so a consumer can render the same
310 /// distinction the stderr warning makes instead of inferring it from the
311 /// counts. `none` whenever `stale` is false.
312 pub warning: BaselineStalenessAdvisory,
313 /// True exactly when `unrecognised_format` is true, or
314 /// `!change_scoped && baseline_entries > 0 && matched_entries < baseline_entries`.
315 /// That is the rule `--fail-on-stale-baseline` applies. Deliberately
316 /// stricter than `stale`: any unmatched entry counts, and so does a file
317 /// this command could not read as its own, which protects nothing at all.
318 /// The second half is suppressed by `change_scoped` and the first is not:
319 /// which command wrote a file does not depend on how much of the project
320 /// the run looked at.
321 ///
322 /// It describes the baseline, not the run's exit code: `health
323 /// --report-only` is an explicit request never to fail, so that run exits 0
324 /// and says so on stderr while still reporting `gate_trips: true` here, and
325 /// `fallow audit` never judges a baseline at all, so its
326 /// `gate_outcomes["stale-baseline"]` stands down beside a section that
327 /// reports `true`.
328 pub gate_trips: bool,
329 /// Entries that matched only by following a file move. Only `health` can
330 /// follow one, in its identity baseline mode; `dead-code` and `dupes` match
331 /// entries by fingerprint and never classify one as moved, so they report
332 /// `0`. Always `0` in health's count mode too.
333 pub moved_entries: usize,
334 /// True when the loaded file is not a baseline of the command that read it:
335 /// it names another command in its top-level `kind`, or it names none and
336 /// carries no key this command's own format writes. Read this, not
337 /// `baseline_entries == 0`, before telling anyone their baseline is the
338 /// wrong file: a baseline saved from a project that had nothing to record
339 /// is legitimately empty and is not a mistake.
340 ///
341 /// Present only when true, so an envelope from a run that loaded its own
342 /// baseline is unchanged. All three commands set it, including `dead-code`,
343 /// which classifies the file before its required fields could reject it.
344 /// A file with no `kind` is the reading a baseline saved before that member
345 /// existed gets, which is why the keys remain the fallback.
346 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347 pub unrecognised_format: bool,
348 /// The command that saved the loaded file, when `unrecognised_format` is
349 /// true and the file names a writer this version knows: `dead-code`,
350 /// `dupes` or `health`, the token the file carries in its top-level `kind`.
351 ///
352 /// Absent, never null, for this command's own baseline, for a file that
353 /// names no writer (an empty file, or a baseline saved before `kind`
354 /// existed) and for a `kind` token this version does not know. The value
355 /// set is OPEN: a later release can add a writer, so treat an unknown value
356 /// as "another command". The CLI computes it once per loaded baseline and
357 /// uses the same value for its stderr note.
358 #[serde(
359 default,
360 skip_serializing_if = "Option::is_none",
361 deserialize_with = "crate::static_str::deserialize_option"
362 )]
363 #[cfg_attr(feature = "schema", schemars(with = "String"))]
364 pub saved_by: Option<crate::static_str::StaticStr>,
365 /// `legacy` when the loaded dead-code baseline has no `identity`, so its
366 /// entries use the old key forms and some of them hold a line. The run
367 /// still applies the file. `--save-baseline` rewrites it with line-free
368 /// keys. Absent for a current baseline and on `dupes` and `health`. The
369 /// value set is OPEN.
370 #[serde(
371 default,
372 skip_serializing_if = "Option::is_none",
373 deserialize_with = "crate::static_str::deserialize_option"
374 )]
375 #[cfg_attr(feature = "schema", schemars(with = "String"))]
376 pub format: Option<crate::static_str::StaticStr>,
377 /// Which channels narrowed this run, present and non-empty exactly when
378 /// `change_scoped` is true. Both members are derived from one function, so
379 /// the boolean and the array cannot disagree.
380 ///
381 /// Read it to decide whether the narrowing is removable: a run narrowed
382 /// only by `diff`, `changed-since`, `changed-files`, `scope`, `file` or
383 /// `issue-type-filter` can be repeated unscoped to judge the baseline,
384 /// while `production`, `workspace` and `changed-workspaces` are the
385 /// caller's own choice about what to analyze and an unscoped repeat would
386 /// contradict it.
387 ///
388 /// The name set is OPEN and the names a command can emit differ per
389 /// command; see [`ScopeReason`].
390 #[serde(default, skip_serializing_if = "BaselineScopeReasons::is_empty")]
391 #[cfg_attr(
392 feature = "schema",
393 schemars(with = "std::collections::BTreeSet<ScopeReason>")
394 )]
395 pub scope_reasons: BaselineScopeReasons,
396}
397
398#[cfg(test)]
399mod tests {
400 use super::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory, ScopeReason};
401
402 fn staleness(scope_reasons: BaselineScopeReasons) -> BaselineStaleness {
403 BaselineStaleness {
404 baseline_entries: 8,
405 matched_entries: 0,
406 stale_entries: 8,
407 current_findings: 0,
408 remaining_findings: None,
409 change_scoped: !scope_reasons.is_empty(),
410 stale: false,
411 warning: BaselineStalenessAdvisory::None,
412 gate_trips: false,
413 moved_entries: 0,
414 unrecognised_format: false,
415 saved_by: None,
416 format: None,
417 scope_reasons,
418 }
419 }
420
421 #[test]
422 fn reasons_serialize_as_a_kebab_case_array() {
423 let value = serde_json::to_value(staleness(
424 BaselineScopeReasons::empty()
425 .with(ScopeReason::Production)
426 .with(ScopeReason::ChangedSince),
427 ))
428 .expect("staleness serializes");
429
430 assert_eq!(
431 value.get("scope_reasons"),
432 Some(&serde_json::json!(["changed-since", "production"]))
433 );
434 }
435
436 #[test]
437 fn reasons_serialize_in_declaration_order_whatever_the_insertion_order() {
438 let forwards = BaselineScopeReasons::empty()
439 .with(ScopeReason::Diff)
440 .with(ScopeReason::IssueTypeFilter)
441 .with(ScopeReason::Production);
442 let backwards = BaselineScopeReasons::empty()
443 .with(ScopeReason::Production)
444 .with(ScopeReason::IssueTypeFilter)
445 .with(ScopeReason::Diff);
446
447 let expected = serde_json::json!(["diff", "issue-type-filter", "production"]);
448 assert_eq!(
449 serde_json::to_value(forwards).expect("reasons serialize"),
450 expected
451 );
452 assert_eq!(
453 serde_json::to_value(backwards).expect("reasons serialize"),
454 expected
455 );
456 }
457
458 /// `fallow report --from` reads a saved envelope back, so the staleness
459 /// object must read back to the bytes it was written as.
460 #[test]
461 fn a_saved_staleness_reads_back_to_the_same_bytes() {
462 for reasons in [
463 BaselineScopeReasons::empty(),
464 BaselineScopeReasons::empty()
465 .with(ScopeReason::Production)
466 .with(ScopeReason::Diff),
467 ] {
468 let mut written = staleness(reasons);
469 written.saved_by = Some("dead-code");
470 written.format = Some("legacy");
471 let value = serde_json::to_value(written).expect("staleness serializes");
472 let read: BaselineStaleness =
473 serde_json::from_value(value.clone()).expect("staleness deserializes");
474 assert_eq!(read.scope_reasons, reasons);
475 assert_eq!(
476 serde_json::to_value(read).expect("staleness serializes again"),
477 value
478 );
479 }
480 }
481
482 #[test]
483 fn an_unscoped_run_keeps_the_member_off_the_wire() {
484 let value = serde_json::to_value(staleness(BaselineScopeReasons::empty()))
485 .expect("staleness serializes");
486
487 assert!(
488 value.get("scope_reasons").is_none(),
489 "a whole-project run must stay byte-identical to a pre-change run"
490 );
491 assert_eq!(value.get("change_scoped"), Some(&serde_json::json!(false)));
492 }
493
494 #[test]
495 fn the_member_is_non_empty_exactly_when_the_run_was_narrowed() {
496 for reason in [
497 ScopeReason::Diff,
498 ScopeReason::ChangedSince,
499 ScopeReason::ChangedFiles,
500 ScopeReason::Workspace,
501 ScopeReason::ChangedWorkspaces,
502 ScopeReason::Scope,
503 ScopeReason::File,
504 ScopeReason::IssueTypeFilter,
505 ScopeReason::Production,
506 ] {
507 let reasons = BaselineScopeReasons::empty().with(reason);
508 assert!(reasons.contains(reason), "{reason:?} must round-trip");
509 assert!(!reasons.is_empty());
510 assert_eq!(reasons.join(), reason.as_str());
511 }
512 }
513
514 /// The same split the GitHub Action and the GitLab template encode in
515 /// `BASELINE_REMOVABLE_SCOPE_REASONS`. Kept as one list here so a new
516 /// channel has to answer the question rather than inherit an answer.
517 #[test]
518 fn removable_channels_are_the_ones_a_repeat_can_drop() {
519 let removable: Vec<&str> = ScopeReason::ALL
520 .into_iter()
521 .filter(|reason| reason.is_removable_by_rerun())
522 .map(ScopeReason::as_str)
523 .collect();
524
525 assert_eq!(
526 removable,
527 [
528 "diff",
529 "changed-since",
530 "package-baselines",
531 "changed-files",
532 "scope",
533 "file",
534 "issue-type-filter"
535 ]
536 );
537 }
538
539 #[test]
540 fn a_set_is_removable_only_when_every_channel_in_it_is() {
541 let removable = BaselineScopeReasons::empty()
542 .with(ScopeReason::ChangedSince)
543 .with(ScopeReason::Scope);
544 assert!(removable.all_removable_by_rerun());
545
546 assert!(
547 !removable
548 .with(ScopeReason::Production)
549 .all_removable_by_rerun(),
550 "a repeat that drops the base ref still runs in production mode"
551 );
552 }
553
554 #[test]
555 fn insert_if_is_the_only_gate_on_membership() {
556 let reasons = BaselineScopeReasons::empty()
557 .insert_if(false, ScopeReason::Diff)
558 .insert_if(true, ScopeReason::Scope);
559
560 assert!(!reasons.contains(ScopeReason::Diff));
561 assert!(reasons.contains(ScopeReason::Scope));
562 }
563
564 #[test]
565 fn every_reason_has_a_distinct_bit_and_a_distinct_name() {
566 let mut combined = BaselineScopeReasons::empty();
567 for reason in ScopeReason::ALL {
568 combined = combined.with(reason);
569 }
570 assert_eq!(combined.iter().count(), ScopeReason::ALL.len());
571
572 let names = ScopeReason::ALL.map(ScopeReason::as_str);
573 let mut sorted = names.to_vec();
574 sorted.sort_unstable();
575 sorted.dedup();
576 assert_eq!(sorted.len(), names.len());
577 }
578
579 #[test]
580 fn the_kebab_name_matches_what_serde_emits() {
581 for reason in ScopeReason::ALL {
582 assert_eq!(
583 serde_json::to_value(reason).expect("reason serializes"),
584 serde_json::json!(reason.as_str()),
585 );
586 }
587 }
588}