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 baseline uses an old key form. For dead-code,
366    /// the file has no `identity`, so some entries hold a line. For `dupes`,
367    /// some entries use the clone key that older versions shared between
368    /// unrelated groups whose code did not parse on its own. The run still
369    /// applies the file. `--save-baseline` rewrites it with current keys.
370    /// Absent for a current baseline and on `health`. The value set is OPEN.
371    #[serde(
372        default,
373        skip_serializing_if = "Option::is_none",
374        deserialize_with = "crate::static_str::deserialize_option"
375    )]
376    #[cfg_attr(feature = "schema", schemars(with = "String"))]
377    pub format: Option<crate::static_str::StaticStr>,
378    /// Which channels narrowed this run, present and non-empty exactly when
379    /// `change_scoped` is true. Both members are derived from one function, so
380    /// the boolean and the array cannot disagree.
381    ///
382    /// Read it to decide whether the narrowing is removable: a run narrowed
383    /// only by `diff`, `changed-since`, `changed-files`, `scope`, `file` or
384    /// `issue-type-filter` can be repeated unscoped to judge the baseline,
385    /// while `production`, `workspace` and `changed-workspaces` are the
386    /// caller's own choice about what to analyze and an unscoped repeat would
387    /// contradict it.
388    ///
389    /// The name set is OPEN and the names a command can emit differ per
390    /// command; see [`ScopeReason`].
391    #[serde(default, skip_serializing_if = "BaselineScopeReasons::is_empty")]
392    #[cfg_attr(
393        feature = "schema",
394        schemars(with = "std::collections::BTreeSet<ScopeReason>")
395    )]
396    pub scope_reasons: BaselineScopeReasons,
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory, ScopeReason};
402
403    fn staleness(scope_reasons: BaselineScopeReasons) -> BaselineStaleness {
404        BaselineStaleness {
405            baseline_entries: 8,
406            matched_entries: 0,
407            stale_entries: 8,
408            current_findings: 0,
409            remaining_findings: None,
410            change_scoped: !scope_reasons.is_empty(),
411            stale: false,
412            warning: BaselineStalenessAdvisory::None,
413            gate_trips: false,
414            moved_entries: 0,
415            unrecognised_format: false,
416            saved_by: None,
417            format: None,
418            scope_reasons,
419        }
420    }
421
422    #[test]
423    fn reasons_serialize_as_a_kebab_case_array() {
424        let value = serde_json::to_value(staleness(
425            BaselineScopeReasons::empty()
426                .with(ScopeReason::Production)
427                .with(ScopeReason::ChangedSince),
428        ))
429        .expect("staleness serializes");
430
431        assert_eq!(
432            value.get("scope_reasons"),
433            Some(&serde_json::json!(["changed-since", "production"]))
434        );
435    }
436
437    #[test]
438    fn reasons_serialize_in_declaration_order_whatever_the_insertion_order() {
439        let forwards = BaselineScopeReasons::empty()
440            .with(ScopeReason::Diff)
441            .with(ScopeReason::IssueTypeFilter)
442            .with(ScopeReason::Production);
443        let backwards = BaselineScopeReasons::empty()
444            .with(ScopeReason::Production)
445            .with(ScopeReason::IssueTypeFilter)
446            .with(ScopeReason::Diff);
447
448        let expected = serde_json::json!(["diff", "issue-type-filter", "production"]);
449        assert_eq!(
450            serde_json::to_value(forwards).expect("reasons serialize"),
451            expected
452        );
453        assert_eq!(
454            serde_json::to_value(backwards).expect("reasons serialize"),
455            expected
456        );
457    }
458
459    /// `fallow report --from` reads a saved envelope back, so the staleness
460    /// object must read back to the bytes it was written as.
461    #[test]
462    fn a_saved_staleness_reads_back_to_the_same_bytes() {
463        for reasons in [
464            BaselineScopeReasons::empty(),
465            BaselineScopeReasons::empty()
466                .with(ScopeReason::Production)
467                .with(ScopeReason::Diff),
468        ] {
469            let mut written = staleness(reasons);
470            written.saved_by = Some("dead-code");
471            written.format = Some("legacy");
472            let value = serde_json::to_value(written).expect("staleness serializes");
473            let read: BaselineStaleness =
474                serde_json::from_value(value.clone()).expect("staleness deserializes");
475            assert_eq!(read.scope_reasons, reasons);
476            assert_eq!(
477                serde_json::to_value(read).expect("staleness serializes again"),
478                value
479            );
480        }
481    }
482
483    #[test]
484    fn an_unscoped_run_keeps_the_member_off_the_wire() {
485        let value = serde_json::to_value(staleness(BaselineScopeReasons::empty()))
486            .expect("staleness serializes");
487
488        assert!(
489            value.get("scope_reasons").is_none(),
490            "a whole-project run must stay byte-identical to a pre-change run"
491        );
492        assert_eq!(value.get("change_scoped"), Some(&serde_json::json!(false)));
493    }
494
495    #[test]
496    fn the_member_is_non_empty_exactly_when_the_run_was_narrowed() {
497        for reason in [
498            ScopeReason::Diff,
499            ScopeReason::ChangedSince,
500            ScopeReason::ChangedFiles,
501            ScopeReason::Workspace,
502            ScopeReason::ChangedWorkspaces,
503            ScopeReason::Scope,
504            ScopeReason::File,
505            ScopeReason::IssueTypeFilter,
506            ScopeReason::Production,
507        ] {
508            let reasons = BaselineScopeReasons::empty().with(reason);
509            assert!(reasons.contains(reason), "{reason:?} must round-trip");
510            assert!(!reasons.is_empty());
511            assert_eq!(reasons.join(), reason.as_str());
512        }
513    }
514
515    /// The same split the GitHub Action and the GitLab template encode in
516    /// `BASELINE_REMOVABLE_SCOPE_REASONS`. Kept as one list here so a new
517    /// channel has to answer the question rather than inherit an answer.
518    #[test]
519    fn removable_channels_are_the_ones_a_repeat_can_drop() {
520        let removable: Vec<&str> = ScopeReason::ALL
521            .into_iter()
522            .filter(|reason| reason.is_removable_by_rerun())
523            .map(ScopeReason::as_str)
524            .collect();
525
526        assert_eq!(
527            removable,
528            [
529                "diff",
530                "changed-since",
531                "package-baselines",
532                "changed-files",
533                "scope",
534                "file",
535                "issue-type-filter"
536            ]
537        );
538    }
539
540    #[test]
541    fn a_set_is_removable_only_when_every_channel_in_it_is() {
542        let removable = BaselineScopeReasons::empty()
543            .with(ScopeReason::ChangedSince)
544            .with(ScopeReason::Scope);
545        assert!(removable.all_removable_by_rerun());
546
547        assert!(
548            !removable
549                .with(ScopeReason::Production)
550                .all_removable_by_rerun(),
551            "a repeat that drops the base ref still runs in production mode"
552        );
553    }
554
555    #[test]
556    fn insert_if_is_the_only_gate_on_membership() {
557        let reasons = BaselineScopeReasons::empty()
558            .insert_if(false, ScopeReason::Diff)
559            .insert_if(true, ScopeReason::Scope);
560
561        assert!(!reasons.contains(ScopeReason::Diff));
562        assert!(reasons.contains(ScopeReason::Scope));
563    }
564
565    #[test]
566    fn every_reason_has_a_distinct_bit_and_a_distinct_name() {
567        let mut combined = BaselineScopeReasons::empty();
568        for reason in ScopeReason::ALL {
569            combined = combined.with(reason);
570        }
571        assert_eq!(combined.iter().count(), ScopeReason::ALL.len());
572
573        let names = ScopeReason::ALL.map(ScopeReason::as_str);
574        let mut sorted = names.to_vec();
575        sorted.sort_unstable();
576        sorted.dedup();
577        assert_eq!(sorted.len(), names.len());
578    }
579
580    #[test]
581    fn the_kebab_name_matches_what_serde_emits() {
582        for reason in ScopeReason::ALL {
583            assert_eq!(
584                serde_json::to_value(reason).expect("reason serializes"),
585                serde_json::json!(reason.as_str()),
586            );
587        }
588    }
589}