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        if let Some(writer) = saved_by(staleness) {
87            return Some(format!(
88                "**{subject} recognises nothing.** `fallow {writer}` saved this baseline, so \
89                 this command reads nothing from it and it suppresses nothing."
90            ));
91        }
92        return Some(format!(
93            "**{subject} recognises nothing.** The baseline has no entries this command \
94             recognises. It may be a baseline saved by another command, or an empty file. Either \
95             way it suppresses nothing."
96        ));
97    }
98    match warning {
99        "partial" => Some(format!(
100            "**{subject} is partially stale.** {stale} of {entries} saved entries matched \
101             nothing this run, so the baseline protects less than what was saved. Re-save it \
102             from a whole-project run."
103        )),
104        "zero-overlap" => Some(format!(
105            "**{subject} matched nothing.** All {entries} saved entries went unmatched. Paths \
106             may have changed, or the baseline was saved elsewhere. Re-save it from a \
107             whole-project run."
108        )),
109        // "none", and any advisory this build does not recognise: an unknown
110        // value must not invent a sentence, but `gate_trips` is a separate
111        // member and a run whose gate rule holds still has something to say.
112        _ if gate_trips => Some(format!(
113            "**{subject} has stale entries.** {stale} of {entries} saved entries matched \
114             nothing this run. The project may be clean, or the baseline may no longer \
115             describe it."
116        )),
117        _ => None,
118    }
119}
120
121/// The command that saved a foreign baseline, from `saved_by`.
122///
123/// The value set is open, so any command-shaped token renders. A value that is
124/// not a plain kebab-case token renders nothing, so the sentence falls back to
125/// the hedged wording instead of quoting unexpected text into Markdown.
126fn saved_by(staleness: &Value) -> Option<&str> {
127    staleness
128        .get("saved_by")
129        .and_then(Value::as_str)
130        .filter(|token| {
131            !token.is_empty()
132                && token
133                    .bytes()
134                    .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
135        })
136}
137
138/// What the sentence calls the baseline it is about.
139///
140/// Unqualified on the single-analysis shapes, where the surface already names
141/// the command that ran. Qualified on the multi-section ones, because "the
142/// baseline" is ambiguous when a run loaded three.
143fn subject(analysis: Option<&str>) -> String {
144    let Some(analysis) = analysis else {
145        return "Baseline".to_owned();
146    };
147    format!("{} baseline", capitalize(analysis))
148}
149
150/// Upper-case the first character, leaving the rest alone so `dead-code` reads
151/// as `Dead-code` rather than losing its hyphen.
152fn capitalize(value: &str) -> String {
153    let mut chars = value.chars();
154    match chars.next() {
155        Some(first) => first.to_uppercase().chain(chars).collect(),
156        None => String::new(),
157    }
158}
159
160fn count(staleness: &Value, key: &str) -> u64 {
161    staleness.get(key).and_then(Value::as_u64).unwrap_or(0)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use fallow_output::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory};
168
169    fn staleness(warning: &str, entries: u64, matched: u64, gate_trips: bool) -> Value {
170        serde_json::json!({
171            "baseline_entries": entries,
172            "matched_entries": matched,
173            "stale_entries": entries - matched,
174            "current_findings": 4,
175            "change_scoped": false,
176            "stale": warning != "none",
177            "warning": warning,
178            "gate_trips": gate_trips,
179            "moved_entries": 0
180        })
181    }
182
183    fn envelope(staleness: &Value) -> Value {
184        serde_json::json!({ "kind": "dead-code", "baseline_staleness": staleness })
185    }
186
187    #[test]
188    fn an_envelope_without_a_baseline_says_nothing() {
189        assert!(advisory_line(&serde_json::json!({ "kind": "dead-code" })).is_none());
190    }
191
192    #[test]
193    fn a_partially_stale_baseline_names_both_counts() {
194        assert_eq!(
195            advisory_line(&envelope(&staleness("partial", 8, 3, true)))
196                .expect("a baseline was loaded"),
197            "**Baseline is partially stale.** 5 of 8 saved entries matched nothing this run, \
198             so the baseline protects less than what was saved. Re-save it from a whole-project \
199             run."
200        );
201    }
202
203    #[test]
204    fn a_baseline_that_matched_nothing_says_so() {
205        assert_eq!(
206            advisory_line(&envelope(&staleness("zero-overlap", 8, 0, true)))
207                .expect("a baseline was loaded"),
208            "**Baseline matched nothing.** All 8 saved entries went unmatched. Paths may have \
209             changed, or the baseline was saved elsewhere. Re-save it from a whole-project run."
210        );
211    }
212
213    /// The case the advisory alone cannot report: a rotted baseline on a
214    /// cleaned project produces no findings to compare, so `stale` stays false
215    /// while the gate rule holds. Without this arm the surface that says the
216    /// gate failed would carry no reason it did.
217    #[test]
218    fn a_tripped_gate_reports_even_with_no_advisory() {
219        assert_eq!(
220            advisory_line(&envelope(&staleness("none", 8, 0, true))).expect("the gate rule held"),
221            "**Baseline has stale entries.** 8 of 8 saved entries matched nothing this run. \
222             The project may be clean, or the baseline may no longer describe it."
223        );
224    }
225
226    #[test]
227    fn a_fresh_baseline_says_nothing() {
228        assert!(advisory_line(&envelope(&staleness("none", 8, 8, false))).is_none());
229    }
230
231    /// A file this command could not read as its own now trips the gate, so
232    /// without its own arm the `gate_trips` fallback would render "0 of 0 saved
233    /// entries matched nothing this run" for a baseline whose problem is that
234    /// nothing read it. Word for word the job summary's pathless line; the
235    /// summary names the file when the Action published a path for it.
236    #[test]
237    fn a_baseline_nothing_recognises_gets_its_own_sentence() {
238        let mut object = staleness("none", 0, 0, true);
239        object["unrecognised_format"] = Value::Bool(true);
240        assert_eq!(
241            advisory_line(&envelope(&object)).expect("the file was not this command's"),
242            "**Baseline recognises nothing.** The baseline has no entries this command \
243             recognises. It may be a baseline saved by another command, or an empty file. Either \
244             way it suppresses nothing."
245        );
246    }
247
248    /// `saved_by` names the writer when the file names a known one. A value
249    /// that is not a kebab-case token falls back to the hedged sentence.
250    #[test]
251    fn a_foreign_baseline_names_the_command_that_saved_it() {
252        let mut object = staleness("none", 0, 0, true);
253        object["unrecognised_format"] = Value::Bool(true);
254        object["saved_by"] = Value::String("health".to_owned());
255        assert_eq!(
256            advisory_line(&envelope(&object)).expect("the file was not this command's"),
257            "**Baseline recognises nothing.** `fallow health` saved this baseline, so this \
258             command reads nothing from it and it suppresses nothing."
259        );
260        object["saved_by"] = Value::String("<b>x</b>".to_owned());
261        assert!(
262            advisory_line(&envelope(&object))
263                .expect("the file was not this command's")
264                .contains("It may be a baseline saved by another command"),
265        );
266    }
267
268    /// The counts are all zero on such a file, so the arm has to be keyed on the
269    /// member rather than on them: a baseline saved from a project that had
270    /// nothing to record carries the same zeros and is not a mistake.
271    #[test]
272    fn an_empty_baseline_of_this_commands_own_says_nothing() {
273        assert!(advisory_line(&envelope(&staleness("none", 0, 0, false))).is_none());
274    }
275
276    /// A multi-section run names which of its baselines was the wrong file.
277    #[test]
278    fn a_multi_section_envelope_names_the_baseline_nothing_recognises() {
279        let mut object = staleness("none", 0, 0, true);
280        object["unrecognised_format"] = Value::Bool(true);
281        let value = serde_json::json!({
282            "kind": "audit",
283            "complexity": { "summary": { "baseline_staleness": object } }
284        });
285        assert!(
286            advisory_line(&value)
287                .expect("a baseline was loaded")
288                .starts_with("**Complexity baseline recognises nothing.**"),
289            "{value}"
290        );
291    }
292
293    /// A narrowed run compares a whole-project baseline against a slice of it,
294    /// so both members are false by construction and the advisory must not
295    /// invent rot the run could not measure.
296    #[test]
297    fn a_change_scoped_run_says_nothing() {
298        let mut object = staleness("none", 8, 0, false);
299        object["change_scoped"] = Value::Bool(true);
300        assert!(advisory_line(&envelope(&object)).is_none());
301    }
302
303    /// An advisory value from a newer build must not produce an invented
304    /// sentence, and must not silence the gate rule either.
305    #[test]
306    fn an_unrecognised_advisory_falls_back_to_the_gate_rule() {
307        let mut object = staleness("none", 8, 2, true);
308        object["warning"] = Value::String("some-future-advisory".to_owned());
309        assert!(advisory_line(&envelope(&object)).is_some());
310
311        object["gate_trips"] = Value::Bool(false);
312        assert!(advisory_line(&envelope(&object)).is_none());
313    }
314
315    #[test]
316    fn health_publishes_inside_summary_and_reads_the_same() {
317        let value = serde_json::json!({
318            "kind": "health",
319            "summary": { "baseline_staleness": staleness("partial", 4, 1, true) }
320        });
321        assert!(
322            advisory_line(&value)
323                .expect("a baseline was loaded")
324                .starts_with("**Baseline is partially stale.**")
325        );
326    }
327
328    /// A run that loaded three baselines must not report the first one's rot
329    /// under another's counts, so each sentence names its own analysis.
330    #[test]
331    fn a_multi_section_envelope_names_each_baseline() {
332        let value = serde_json::json!({
333            "kind": "audit",
334            "dead_code": { "baseline_staleness": staleness("zero-overlap", 3, 0, true) },
335            "complexity": {
336                "summary": { "baseline_staleness": staleness("partial", 8, 2, true) }
337            }
338        });
339
340        let line = advisory_line(&value).expect("two baselines were loaded");
341
342        assert!(
343            line.contains("**Dead-code baseline matched nothing.**"),
344            "{line}"
345        );
346        assert!(
347            line.contains("**Complexity baseline is partially stale.**"),
348            "{line}"
349        );
350    }
351
352    /// The live path holds the object typed and the saved path reads it off the
353    /// envelope. They must produce the same string, or `report --from` stops
354    /// being byte-identical to a direct render.
355    #[test]
356    fn the_live_and_saved_advisories_agree() {
357        for (warning, matched, gate_trips, unrecognised_format) in [
358            (BaselineStalenessAdvisory::Partial, 3, true, false),
359            (BaselineStalenessAdvisory::ZeroOverlap, 0, true, false),
360            (BaselineStalenessAdvisory::None, 0, true, false),
361            (BaselineStalenessAdvisory::None, 8, false, false),
362            (BaselineStalenessAdvisory::None, 8, true, true),
363        ] {
364            let typed = BaselineStaleness {
365                baseline_entries: 8,
366                matched_entries: matched,
367                stale_entries: 8 - matched,
368                current_findings: 4,
369                remaining_findings: None,
370                change_scoped: false,
371                stale: !matches!(warning, BaselineStalenessAdvisory::None),
372                warning,
373                gate_trips,
374                moved_entries: 0,
375                unrecognised_format,
376                saved_by: None,
377                format: None,
378                scope_reasons: BaselineScopeReasons::empty(),
379            };
380            let envelope = serde_json::json!({ "baseline_staleness": typed });
381
382            assert_eq!(
383                advisory_line_for_staleness(Some(&typed)),
384                advisory_line(&envelope),
385                "{warning:?} must render the same from both paths"
386            );
387        }
388    }
389
390    #[test]
391    fn a_live_run_without_a_baseline_says_nothing() {
392        assert!(advisory_line_for_staleness(None).is_none());
393    }
394}