Skip to main content

fallow_cli/report/
baseline_advisory_text.rs

1//! One reader for the envelope's `baseline_staleness`, shared by every review
2//! surface that states an advisory.
3//!
4//! The gate inventory line already tells a reviewer that `stale-baseline`
5//! failed or stood down. It does not say what went stale, and the counts that
6//! answer that are on the envelope: the step log and the job summary print them
7//! and the sticky comment did not, so a repository whose baseline had rotted
8//! saw it on the surface nobody opens and not on the one people read (#2675).
9//!
10//! The fact clauses are word for word the job summary's
11//! (`action/scripts/summary.sh`), which is what a reader comparing the two
12//! surfaces gets from having one advisory. The remedy clause is deliberately
13//! channel-free: one Rust renderer serves GitHub and GitLab and cannot know
14//! whether the reader re-saves through the `save-baseline` input,
15//! `FALLOW_SAVE_BASELINE` or `--save-baseline`, so it names the run rather than
16//! the knob. Do not "fix" that divergence by naming one channel here.
17//!
18//! The envelope arrives untyped, exactly as `fallow report --from` holds it,
19//! and the live path routes its typed object through the same formatter so the
20//! saved render stays byte-identical to the direct one.
21
22use serde_json::Value;
23
24/// The advisory for every loaded baseline that earned one, or `None` when the
25/// run loaded no baseline or every baseline is fresh enough to say nothing.
26///
27/// Keyed on `warning` and `gate_trips`, the two members that exist so a
28/// renderer does not infer which advisory applies from the counts. A
29/// change-scoped run says nothing here on purpose: it cannot judge a
30/// whole-project baseline, and both members are false by construction, which
31/// is the stood-down verdict the gate inventory line already reports.
32///
33/// A multi-section envelope carries up to three baselines, so this reports one
34/// sentence per site and names the analysis, rather than reporting the first
35/// one's rot under the counts of whichever section came first.
36pub fn advisory_line(envelope: &Value) -> Option<String> {
37    let root = envelope.as_object()?;
38    let sentences = fallow_types::envelope_sites::baseline_staleness_objects(root)
39        .filter_map(|(staleness, analysis)| advisory_sentence(staleness, analysis))
40        .collect::<Vec<_>>();
41    if sentences.is_empty() {
42        return None;
43    }
44    Some(sentences.join(" "))
45}
46
47/// [`advisory_line`] for a live run holding one staleness object typed.
48///
49/// Routed through the same formatter on purpose: `fallow report --from` must
50/// render byte-identically to the direct `--format` run, which is a contract
51/// with its own parity suite, so the live and saved paths cannot each format
52/// the advisory their own way. The root site is unlabelled, which is where
53/// `dead-code` and `dupes` publish and how `health`'s `summary` site is also
54/// reported, so a single-baseline command renders one unqualified sentence.
55pub fn advisory_line_for_staleness(
56    staleness: Option<&fallow_output::BaselineStaleness>,
57) -> Option<String> {
58    let staleness = staleness?;
59    advisory_line(&serde_json::json!({ "baseline_staleness": staleness }))
60}
61
62/// One sentence for one loaded baseline, or `None` when it earned no advisory.
63fn advisory_sentence(staleness: &Value, analysis: Option<&str>) -> Option<String> {
64    let warning = staleness
65        .get("warning")
66        .and_then(Value::as_str)
67        .unwrap_or("none");
68    let gate_trips = staleness
69        .get("gate_trips")
70        .and_then(Value::as_bool)
71        .unwrap_or(false);
72    let entries = count(staleness, "baseline_entries");
73    let stale = count(staleness, "stale_entries");
74    let subject = subject(analysis);
75    // Checked before the advisory arms, and before the `gate_trips` fallback the
76    // same file now reaches: its counts are all zero, so that arm would render
77    // "0 of 0 saved entries matched nothing this run" next to a baseline whose
78    // problem is that nothing read it. Word for word the job summary's line for a
79    // baseline whose path the Action never saw. The summary names the file when it
80    // has one. This renderer never can, because the envelope carries no path.
81    if staleness
82        .get("unrecognised_format")
83        .and_then(Value::as_bool)
84        .unwrap_or(false)
85    {
86        return Some(format!(
87            "**{subject} recognises nothing.** The baseline has no entries this command \
88             recognises. It may be a baseline saved by another command, or an empty file. Either \
89             way it suppresses nothing."
90        ));
91    }
92    match warning {
93        "partial" => Some(format!(
94            "**{subject} is partially stale.** {stale} of {entries} saved entries matched \
95             nothing this run, so the baseline protects less than what was saved. Re-save it \
96             from a whole-project run."
97        )),
98        "zero-overlap" => Some(format!(
99            "**{subject} matched nothing.** All {entries} saved entries went unmatched. Paths \
100             may have changed, or the baseline was saved elsewhere. Re-save it from a \
101             whole-project run."
102        )),
103        // "none", and any advisory this build does not recognise: an unknown
104        // value must not invent a sentence, but `gate_trips` is a separate
105        // member and a run whose gate rule holds still has something to say.
106        _ if gate_trips => Some(format!(
107            "**{subject} has stale entries.** {stale} of {entries} saved entries matched \
108             nothing this run. The project may be clean, or the baseline may no longer \
109             describe it."
110        )),
111        _ => None,
112    }
113}
114
115/// What the sentence calls the baseline it is about.
116///
117/// Unqualified on the single-analysis shapes, where the surface already names
118/// the command that ran. Qualified on the multi-section ones, because "the
119/// baseline" is ambiguous when a run loaded three.
120fn subject(analysis: Option<&str>) -> String {
121    let Some(analysis) = analysis else {
122        return "Baseline".to_owned();
123    };
124    format!("{} baseline", capitalize(analysis))
125}
126
127/// Upper-case the first character, leaving the rest alone so `dead-code` reads
128/// as `Dead-code` rather than losing its hyphen.
129fn capitalize(value: &str) -> String {
130    let mut chars = value.chars();
131    match chars.next() {
132        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
133        None => String::new(),
134    }
135}
136
137fn count(staleness: &Value, key: &str) -> u64 {
138    staleness.get(key).and_then(Value::as_u64).unwrap_or(0)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use fallow_output::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory};
145
146    fn staleness(warning: &str, entries: u64, matched: u64, gate_trips: bool) -> Value {
147        serde_json::json!({
148            "baseline_entries": entries,
149            "matched_entries": matched,
150            "stale_entries": entries - matched,
151            "current_findings": 4,
152            "change_scoped": false,
153            "stale": warning != "none",
154            "warning": warning,
155            "gate_trips": gate_trips,
156            "moved_entries": 0
157        })
158    }
159
160    fn envelope(staleness: &Value) -> Value {
161        serde_json::json!({ "kind": "dead-code", "baseline_staleness": staleness })
162    }
163
164    #[test]
165    fn an_envelope_without_a_baseline_says_nothing() {
166        assert!(advisory_line(&serde_json::json!({ "kind": "dead-code" })).is_none());
167    }
168
169    #[test]
170    fn a_partially_stale_baseline_names_both_counts() {
171        assert_eq!(
172            advisory_line(&envelope(&staleness("partial", 8, 3, true)))
173                .expect("a baseline was loaded"),
174            "**Baseline is partially stale.** 5 of 8 saved entries matched nothing this run, \
175             so the baseline protects less than what was saved. Re-save it from a whole-project \
176             run."
177        );
178    }
179
180    #[test]
181    fn a_baseline_that_matched_nothing_says_so() {
182        assert_eq!(
183            advisory_line(&envelope(&staleness("zero-overlap", 8, 0, true)))
184                .expect("a baseline was loaded"),
185            "**Baseline matched nothing.** All 8 saved entries went unmatched. Paths may have \
186             changed, or the baseline was saved elsewhere. Re-save it from a whole-project run."
187        );
188    }
189
190    /// The case the advisory alone cannot report: a rotted baseline on a
191    /// cleaned project produces no findings to compare, so `stale` stays false
192    /// while the gate rule holds. Without this arm the surface that says the
193    /// gate failed would carry no reason it did.
194    #[test]
195    fn a_tripped_gate_reports_even_with_no_advisory() {
196        assert_eq!(
197            advisory_line(&envelope(&staleness("none", 8, 0, true))).expect("the gate rule held"),
198            "**Baseline has stale entries.** 8 of 8 saved entries matched nothing this run. \
199             The project may be clean, or the baseline may no longer describe it."
200        );
201    }
202
203    #[test]
204    fn a_fresh_baseline_says_nothing() {
205        assert!(advisory_line(&envelope(&staleness("none", 8, 8, false))).is_none());
206    }
207
208    /// A file this command could not read as its own now trips the gate, so
209    /// without its own arm the `gate_trips` fallback would render "0 of 0 saved
210    /// entries matched nothing this run" for a baseline whose problem is that
211    /// nothing read it. Word for word the job summary's pathless line; the
212    /// summary names the file when the Action published a path for it.
213    #[test]
214    fn a_baseline_nothing_recognises_gets_its_own_sentence() {
215        let mut object = staleness("none", 0, 0, true);
216        object["unrecognised_format"] = Value::Bool(true);
217        assert_eq!(
218            advisory_line(&envelope(&object)).expect("the file was not this command's"),
219            "**Baseline recognises nothing.** The baseline has no entries this command \
220             recognises. It may be a baseline saved by another command, or an empty file. Either \
221             way it suppresses nothing."
222        );
223    }
224
225    /// The counts are all zero on such a file, so the arm has to be keyed on the
226    /// member rather than on them: a baseline saved from a project that had
227    /// nothing to record carries the same zeros and is not a mistake.
228    #[test]
229    fn an_empty_baseline_of_this_commands_own_says_nothing() {
230        assert!(advisory_line(&envelope(&staleness("none", 0, 0, false))).is_none());
231    }
232
233    /// A multi-section run names which of its baselines was the wrong file.
234    #[test]
235    fn a_multi_section_envelope_names_the_baseline_nothing_recognises() {
236        let mut object = staleness("none", 0, 0, true);
237        object["unrecognised_format"] = Value::Bool(true);
238        let value = serde_json::json!({
239            "kind": "audit",
240            "complexity": { "summary": { "baseline_staleness": object } }
241        });
242        assert!(
243            advisory_line(&value)
244                .expect("a baseline was loaded")
245                .starts_with("**Complexity baseline recognises nothing.**"),
246            "{value}"
247        );
248    }
249
250    /// A narrowed run compares a whole-project baseline against a slice of it,
251    /// so both members are false by construction and the advisory must not
252    /// invent rot the run could not measure.
253    #[test]
254    fn a_change_scoped_run_says_nothing() {
255        let mut object = staleness("none", 8, 0, false);
256        object["change_scoped"] = Value::Bool(true);
257        assert!(advisory_line(&envelope(&object)).is_none());
258    }
259
260    /// An advisory value from a newer build must not produce an invented
261    /// sentence, and must not silence the gate rule either.
262    #[test]
263    fn an_unrecognised_advisory_falls_back_to_the_gate_rule() {
264        let mut object = staleness("none", 8, 2, true);
265        object["warning"] = Value::String("some-future-advisory".to_owned());
266        assert!(advisory_line(&envelope(&object)).is_some());
267
268        object["gate_trips"] = Value::Bool(false);
269        assert!(advisory_line(&envelope(&object)).is_none());
270    }
271
272    #[test]
273    fn health_publishes_inside_summary_and_reads_the_same() {
274        let value = serde_json::json!({
275            "kind": "health",
276            "summary": { "baseline_staleness": staleness("partial", 4, 1, true) }
277        });
278        assert!(
279            advisory_line(&value)
280                .expect("a baseline was loaded")
281                .starts_with("**Baseline is partially stale.**")
282        );
283    }
284
285    /// A run that loaded three baselines must not report the first one's rot
286    /// under another's counts, so each sentence names its own analysis.
287    #[test]
288    fn a_multi_section_envelope_names_each_baseline() {
289        let value = serde_json::json!({
290            "kind": "audit",
291            "dead_code": { "baseline_staleness": staleness("zero-overlap", 3, 0, true) },
292            "complexity": {
293                "summary": { "baseline_staleness": staleness("partial", 8, 2, true) }
294            }
295        });
296
297        let line = advisory_line(&value).expect("two baselines were loaded");
298
299        assert!(
300            line.contains("**Dead-code baseline matched nothing.**"),
301            "{line}"
302        );
303        assert!(
304            line.contains("**Complexity baseline is partially stale.**"),
305            "{line}"
306        );
307    }
308
309    /// The live path holds the object typed and the saved path reads it off the
310    /// envelope. They must produce the same string, or `report --from` stops
311    /// being byte-identical to a direct render.
312    #[test]
313    fn the_live_and_saved_advisories_agree() {
314        for (warning, matched, gate_trips, unrecognised_format) in [
315            (BaselineStalenessAdvisory::Partial, 3, true, false),
316            (BaselineStalenessAdvisory::ZeroOverlap, 0, true, false),
317            (BaselineStalenessAdvisory::None, 0, true, false),
318            (BaselineStalenessAdvisory::None, 8, false, false),
319            (BaselineStalenessAdvisory::None, 8, true, true),
320        ] {
321            let typed = BaselineStaleness {
322                baseline_entries: 8,
323                matched_entries: matched,
324                stale_entries: 8 - matched,
325                current_findings: 4,
326                change_scoped: false,
327                stale: !matches!(warning, BaselineStalenessAdvisory::None),
328                warning,
329                gate_trips,
330                moved_entries: 0,
331                unrecognised_format,
332                scope_reasons: BaselineScopeReasons::empty(),
333            };
334            let envelope = serde_json::json!({ "baseline_staleness": typed });
335
336            assert_eq!(
337                advisory_line_for_staleness(Some(&typed)),
338                advisory_line(&envelope),
339                "{warning:?} must render the same from both paths"
340            );
341        }
342    }
343
344    #[test]
345    fn a_live_run_without_a_baseline_says_nothing() {
346        assert!(advisory_line_for_staleness(None).is_none());
347    }
348}