Skip to main content

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}