1use 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#[derive(Debug, Clone, Copy, Serialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub enum SuppressionInventorySchemaVersion {
17 #[serde(rename = "1")]
19 V1,
20}
21
22#[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 pub schema_version: SuppressionInventorySchemaVersion,
37 pub summary: SuppressionInventorySummary,
39 pub files: Vec<SuppressionInventoryFile>,
41}
42
43#[derive(Debug, Clone, Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct SuppressionInventorySummary {
47 pub total: usize,
49 pub files: usize,
51 pub without_reason: usize,
53 pub stale: usize,
57 pub by_kind: Vec<SuppressionKindCount>,
61}
62
63#[derive(Debug, Clone, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct SuppressionKindCount {
67 pub kind: Option<String>,
71 pub count: usize,
73}
74
75#[derive(Debug, Clone, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct SuppressionInventoryFile {
79 #[serde(serialize_with = "serde_path::serialize")]
81 pub path: PathBuf,
82 pub suppressions: Vec<SuppressionInventoryEntry>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct SuppressionInventoryEntry {
90 pub line: u32,
92 pub kind: Option<String>,
98 pub level: SuppressionInventoryLevel,
101 pub origin: SuppressionInventoryOrigin,
105 pub reason: Option<String>,
107 pub reason_present: bool,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[serde(rename_all = "lowercase")]
115pub enum SuppressionInventoryLevel {
116 File,
118 Line,
120}
121
122#[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 Comment,
129}
130
131#[derive(Clone, Copy)]
133pub struct SuppressionInventoryOutputInput<'a> {
134 pub active: &'a [ActiveSuppression],
136 pub stale: &'a [StaleSuppression],
139 pub root: &'a Path,
141}
142
143#[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
167fn 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
234fn 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
257pub 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 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 stale("/repo/a.ts", 1, Some("unused-export"), false),
373 stale("/repo/a.ts", 1, Some("unused-export"), true),
376 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}