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