Skip to main content

fallow_output/
suppressions.rs

1//! Suppression inventory output contracts (`fallow suppressions`).
2
3use std::path::{Path, PathBuf};
4
5use fallow_types::results::{ActiveSuppression, StaleSuppression, SuppressionOrigin};
6use fallow_types::serde_path;
7use rustc_hash::{FxHashMap, FxHashSet};
8use serde::Serialize;
9
10use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
11
12/// The `fallow suppressions --format json` schema version. Independently
13/// versioned from the main contract, mirroring `SecuritySchemaVersion`.
14#[derive(Debug, Clone, Copy, Serialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub enum SuppressionInventorySchemaVersion {
17    /// First release of the `fallow suppressions --format json` shape.
18    #[serde(rename = "1")]
19    V1,
20}
21
22/// The `fallow suppressions --format json` envelope. `FallowOutput`
23/// discriminates it by the `kind: "suppression-inventory"` tag.
24///
25/// A read-only projection over the suppression markers present in analyzed
26/// files this run: nothing here is a finding, and the command that emits it
27/// always exits 0.
28#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[cfg_attr(
31    feature = "schema",
32    schemars(title = "fallow suppressions --format json")
33)]
34pub struct SuppressionInventoryOutput {
35    /// Schema version of this envelope.
36    pub schema_version: SuppressionInventorySchemaVersion,
37    /// What the run was asked to narrow and whether it did. See
38    /// [`crate::RequestOutcomes`] for the full contract.
39    ///
40    /// `fallow suppressions` accepts `--changed-since`, and an unresolvable ref
41    /// widens the inventory to the whole project rather than failing the run.
42    /// Until this member existed the only account of that was a stderr line,
43    /// which `--quiet` removes, so an inventory read as scoped to the change
44    /// could silently be the whole project's (issue #2734).
45    ///
46    /// The command applies no diff filter, so the object carries the
47    /// `changed-since` entry only. Omitted when the run was asked for nothing,
48    /// which keeps an inventory that passed no narrowing flag byte-identical and
49    /// leaves `schema_version` at `1`.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub request_outcomes: Option<crate::RequestOutcomes>,
52    /// Project-level totals over the scoped inventory.
53    pub summary: SuppressionInventorySummary,
54    /// Per-file suppression listings, sorted by path then line.
55    pub files: Vec<SuppressionInventoryFile>,
56}
57
58/// Project-level totals for the suppression inventory.
59#[derive(Debug, Clone, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct SuppressionInventorySummary {
62    /// Total suppression markers in scope.
63    pub total: usize,
64    /// Number of files carrying at least one marker.
65    pub files: usize,
66    /// Markers without a human-authored `--` reason.
67    pub without_reason: usize,
68    /// Markers that also appear as stale-suppression findings this run. This
69    /// is a JOIN against the existing stale-suppression detector's output
70    /// (matched by file and kind), not a new detection.
71    pub stale: usize,
72    /// Marker counts per suppressed kind, sorted by count (descending) then
73    /// kind. `kind` is `null` for blanket markers, mirroring the per-entry
74    /// contract.
75    pub by_kind: Vec<SuppressionKindCount>,
76}
77
78/// One `by_kind` row in the suppression inventory summary.
79#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct SuppressionKindCount {
82    /// The suppressed issue kind in kebab-case, or `null` for blanket markers
83    /// (rendered as "blanket" in human output; machine consumers branch on
84    /// `null`).
85    pub kind: Option<String>,
86    /// Number of markers targeting this kind.
87    pub count: usize,
88}
89
90/// One file's suppression listing.
91#[derive(Debug, Clone, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub struct SuppressionInventoryFile {
94    /// Project-root-relative path, forward-slash separated.
95    #[serde(serialize_with = "serde_path::serialize")]
96    pub path: PathBuf,
97    /// Markers in this file, sorted by line.
98    pub suppressions: Vec<SuppressionInventoryEntry>,
99}
100
101/// One suppression marker in the inventory.
102#[derive(Debug, Clone, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct SuppressionInventoryEntry {
105    /// 1-based line of the suppression comment itself; 0 only if unknown.
106    pub line: u32,
107    /// The suppressed issue kind in kebab-case (e.g. `"unused-export"`), or
108    /// `null` for a blanket marker that suppresses every kind on its target.
109    /// Human output renders the blanket case as the literal word "blanket";
110    /// the JSON contract deliberately keeps `null` so machine consumers
111    /// branch on `null` instead of a magic string.
112    pub kind: Option<String>,
113    /// Whether the marker is file-wide (`fallow-ignore-file`) or line-scoped
114    /// (`fallow-ignore-next-line`).
115    pub level: SuppressionInventoryLevel,
116    /// How the suppression was authored. `"comment"` in v1; `"jsdoc_tag"` is
117    /// reserved for a follow-up if `@expected-unused` entries enter the
118    /// active inventory.
119    pub origin: SuppressionInventoryOrigin,
120    /// Human-authored reason after `--`; `null` when absent.
121    pub reason: Option<String>,
122    /// Whether a human-authored reason is present.
123    pub reason_present: bool,
124}
125
126/// Suppression marker scope.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129#[serde(rename_all = "lowercase")]
130pub enum SuppressionInventoryLevel {
131    /// A `fallow-ignore-file` marker covering the whole file.
132    File,
133    /// A `fallow-ignore-next-line` marker covering one line.
134    Line,
135}
136
137/// How a suppression in the inventory was authored.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140#[serde(rename_all = "snake_case")]
141pub enum SuppressionInventoryOrigin {
142    /// A `// fallow-ignore-next-line` or `// fallow-ignore-file` comment.
143    Comment,
144}
145
146/// Inputs for building `fallow suppressions --format json`.
147#[derive(Clone)]
148pub struct SuppressionInventoryOutputInput<'a> {
149    /// Scoped active suppressions (absolute paths).
150    pub active: &'a [ActiveSuppression],
151    /// This run's stale-suppression findings (absolute paths), used only for
152    /// the `summary.stale` join.
153    pub stale: &'a [StaleSuppression],
154    /// Project root used to relativize paths.
155    pub root: &'a Path,
156    /// What became of the narrowing requests the run received, or `None` when
157    /// it received none.
158    pub request_outcomes: Option<crate::RequestOutcomes>,
159}
160
161/// Build the typed suppression inventory envelope: sort by `(path, line)`,
162/// group per file, and compute the summary (including the stale join).
163#[must_use]
164pub fn build_suppression_inventory_output(
165    input: SuppressionInventoryOutputInput<'_>,
166) -> SuppressionInventoryOutput {
167    let mut sorted: Vec<&ActiveSuppression> = input.active.iter().collect();
168    sorted.sort_by(|a, b| {
169        a.path
170            .cmp(&b.path)
171            .then(a.comment_line.cmp(&b.comment_line))
172            .then(a.kind.cmp(&b.kind))
173    });
174
175    let files = group_by_file(&sorted, input.root);
176    let summary = build_summary(&sorted, &files, input.stale);
177
178    SuppressionInventoryOutput {
179        schema_version: SuppressionInventorySchemaVersion::V1,
180        request_outcomes: input.request_outcomes,
181        summary,
182        files,
183    }
184}
185
186/// Group sorted suppressions into per-file listings with root-relative paths.
187fn group_by_file(sorted: &[&ActiveSuppression], root: &Path) -> Vec<SuppressionInventoryFile> {
188    let mut files: Vec<SuppressionInventoryFile> = Vec::new();
189    let mut current: Option<&Path> = None;
190    for supp in sorted {
191        if current != Some(supp.path.as_path()) {
192            files.push(SuppressionInventoryFile {
193                path: supp
194                    .path
195                    .strip_prefix(root)
196                    .unwrap_or(&supp.path)
197                    .to_path_buf(),
198                suppressions: Vec::new(),
199            });
200            current = Some(supp.path.as_path());
201        }
202        if let Some(file) = files.last_mut() {
203            file.suppressions.push(inventory_entry(supp));
204        }
205    }
206    files
207}
208
209fn inventory_entry(supp: &ActiveSuppression) -> SuppressionInventoryEntry {
210    SuppressionInventoryEntry {
211        line: supp.comment_line,
212        kind: supp.kind.clone(),
213        level: if supp.is_file_level {
214            SuppressionInventoryLevel::File
215        } else {
216            SuppressionInventoryLevel::Line
217        },
218        origin: SuppressionInventoryOrigin::Comment,
219        reason: supp.reason.clone(),
220        reason_present: supp.reason.is_some(),
221    }
222}
223
224fn build_summary(
225    sorted: &[&ActiveSuppression],
226    files: &[SuppressionInventoryFile],
227    stale: &[StaleSuppression],
228) -> SuppressionInventorySummary {
229    let without_reason = sorted.iter().filter(|s| s.reason.is_none()).count();
230
231    let mut kind_counts: FxHashMap<Option<&str>, usize> = FxHashMap::default();
232    for supp in sorted {
233        *kind_counts.entry(supp.kind.as_deref()).or_default() += 1;
234    }
235    let mut by_kind: Vec<SuppressionKindCount> = kind_counts
236        .into_iter()
237        .map(|(kind, count)| SuppressionKindCount {
238            kind: kind.map(str::to_owned),
239            count,
240        })
241        .collect();
242    by_kind.sort_by(|a, b| b.count.cmp(&a.count).then(a.kind.cmp(&b.kind)));
243
244    SuppressionInventorySummary {
245        total: sorted.len(),
246        files: files.len(),
247        without_reason,
248        stale: stale_join_count(sorted, stale),
249        by_kind,
250    }
251}
252
253/// Count this run's stale-suppression findings whose `(path, kind)` matches a
254/// scoped active marker. A JOIN against the stale detector's existing output,
255/// never a new detection: missing-reason findings and JSDoc-tag origins are
256/// excluded (the former are counted by `without_reason`, the latter do not
257/// flow through the active inventory in v1).
258fn stale_join_count(sorted: &[&ActiveSuppression], stale: &[StaleSuppression]) -> usize {
259    let active_keys: FxHashSet<(&Path, Option<&str>)> = sorted
260        .iter()
261        .map(|s| (s.path.as_path(), s.kind.as_deref()))
262        .collect();
263
264    stale
265        .iter()
266        .filter(|entry| !entry.missing_reason)
267        .filter(|entry| match &entry.origin {
268            SuppressionOrigin::Comment { issue_kind, .. } => {
269                active_keys.contains(&(entry.path.as_path(), issue_kind.as_deref()))
270            }
271            SuppressionOrigin::JsdocTag { .. } => false,
272        })
273        .count()
274}
275
276/// Serialize `fallow suppressions --format json`.
277///
278/// # Errors
279///
280/// Returns a serde error when the suppression inventory output cannot be
281/// converted to JSON.
282pub fn serialize_suppression_inventory_json_output(
283    output: SuppressionInventoryOutput,
284    analysis_run_id: Option<&str>,
285) -> Result<serde_json::Value, serde_json::Error> {
286    let mut value = serialize_named_json_output(output, "suppression-inventory")?;
287    attach_telemetry_meta(&mut value, analysis_run_id);
288    Ok(value)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use fallow_types::output::IssueAction;
295
296    fn active(
297        path: &str,
298        line: u32,
299        kind: Option<&str>,
300        reason: Option<&str>,
301        file_level: bool,
302    ) -> ActiveSuppression {
303        ActiveSuppression {
304            path: PathBuf::from(path),
305            kind: kind.map(str::to_owned),
306            is_file_level: file_level,
307            reason: reason.map(str::to_owned),
308            comment_line: line,
309        }
310    }
311
312    fn stale(path: &str, line: u32, kind: Option<&str>, missing_reason: bool) -> StaleSuppression {
313        StaleSuppression {
314            finding_id: None,
315            path: PathBuf::from(path),
316            line,
317            col: 0,
318            origin: SuppressionOrigin::Comment {
319                issue_kind: kind.map(str::to_owned),
320                reason: None,
321                is_file_level: false,
322                kind_known: true,
323            },
324            missing_reason,
325            actions: Vec::<IssueAction>::new(),
326            effective_severity: None,
327        }
328    }
329
330    #[test]
331    fn inventory_groups_sorts_and_relativizes() {
332        let actives = vec![
333            active("/repo/src/b.ts", 9, Some("unused-export"), None, false),
334            active(
335                "/repo/src/a.ts",
336                4,
337                Some("unused-export"),
338                Some("public compatibility export"),
339                false,
340            ),
341            active("/repo/src/a.ts", 2, None, None, true),
342        ];
343        let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
344            active: &actives,
345            stale: &[],
346            root: Path::new("/repo"),
347            request_outcomes: None,
348        });
349
350        assert_eq!(output.summary.total, 3);
351        assert_eq!(output.summary.files, 2);
352        assert_eq!(output.summary.without_reason, 2);
353        assert_eq!(output.files.len(), 2);
354        let first = &output.files[0];
355        assert_eq!(first.path.to_string_lossy().replace('\\', "/"), "src/a.ts");
356        assert_eq!(first.suppressions[0].line, 2);
357        assert_eq!(first.suppressions[0].kind, None);
358        assert_eq!(first.suppressions[0].level, SuppressionInventoryLevel::File);
359        assert_eq!(first.suppressions[1].line, 4);
360        assert!(first.suppressions[1].reason_present);
361    }
362
363    #[test]
364    fn by_kind_sorts_by_count_desc_then_kind() {
365        let actives = vec![
366            active("/repo/a.ts", 1, Some("unused-export"), None, false),
367            active("/repo/a.ts", 3, Some("unused-export"), None, false),
368            active("/repo/a.ts", 5, Some("complexity"), None, false),
369            active("/repo/a.ts", 7, None, None, false),
370        ];
371        let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
372            active: &actives,
373            stale: &[],
374            root: Path::new("/repo"),
375            request_outcomes: None,
376        });
377
378        let by_kind = &output.summary.by_kind;
379        assert_eq!(by_kind[0].kind.as_deref(), Some("unused-export"));
380        assert_eq!(by_kind[0].count, 2);
381        // Ties sort blanket (None) before named kinds.
382        assert_eq!(by_kind[1].kind, None);
383        assert_eq!(by_kind[2].kind.as_deref(), Some("complexity"));
384    }
385
386    #[test]
387    fn stale_join_counts_matching_path_and_kind_only() {
388        let actives = vec![
389            active("/repo/a.ts", 1, Some("unused-export"), None, false),
390            active("/repo/b.ts", 1, Some("complexity"), None, false),
391        ];
392        let stales = vec![
393            // Joins: path+kind matches an active marker.
394            stale("/repo/a.ts", 1, Some("unused-export"), false),
395            // Does not join: missing-reason findings are counted by
396            // `without_reason`, not `stale`.
397            stale("/repo/a.ts", 1, Some("unused-export"), true),
398            // Does not join: no active marker on that path+kind.
399            stale("/repo/c.ts", 1, Some("unused-export"), false),
400        ];
401        let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
402            active: &actives,
403            stale: &stales,
404            root: Path::new("/repo"),
405            request_outcomes: None,
406        });
407
408        assert_eq!(output.summary.stale, 1);
409    }
410
411    #[test]
412    fn json_output_uses_output_owned_root_contract() {
413        let actives = vec![active(
414            "/repo/src/api/client.ts",
415            4,
416            Some("unused-export"),
417            Some("public compatibility export"),
418            false,
419        )];
420        let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
421            active: &actives,
422            stale: &[],
423            root: Path::new("/repo"),
424            request_outcomes: None,
425        });
426
427        let value = serialize_suppression_inventory_json_output(output, Some("run-suppressions"))
428            .expect("suppression inventory output should serialize");
429
430        assert_eq!(value["kind"], "suppression-inventory");
431        assert_eq!(value["schema_version"], "1");
432        assert_eq!(value["files"][0]["path"], "src/api/client.ts");
433        let entry = &value["files"][0]["suppressions"][0];
434        assert_eq!(entry["line"], 4);
435        assert_eq!(entry["kind"], "unused-export");
436        assert_eq!(entry["level"], "line");
437        assert_eq!(entry["origin"], "comment");
438        assert_eq!(entry["reason"], "public compatibility export");
439        assert_eq!(entry["reason_present"], true);
440        assert_eq!(
441            value["_meta"]["telemetry"]["analysis_run_id"],
442            "run-suppressions"
443        );
444    }
445
446    #[test]
447    fn blanket_marker_serializes_null_kind() {
448        let actives = vec![active("/repo/a.ts", 2, None, None, true)];
449        let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
450            active: &actives,
451            stale: &[],
452            root: Path::new("/repo"),
453            request_outcomes: None,
454        });
455
456        let value = serialize_suppression_inventory_json_output(output, None)
457            .expect("suppression inventory output should serialize");
458
459        let entry = &value["files"][0]["suppressions"][0];
460        assert!(entry["kind"].is_null(), "blanket kind must stay JSON null");
461        assert_eq!(entry["level"], "file");
462        assert_eq!(entry["reason_present"], false);
463        assert!(entry["reason"].is_null());
464        assert!(value["summary"]["by_kind"][0]["kind"].is_null());
465    }
466}