Skip to main content

fallow_cli/regression/
counts.rs

1use fallow_core::results::AnalysisResults;
2
3/// Regression baseline: stores issue counts per type for comparison.
4///
5/// Unlike `BaselineData` which stores individual issue identities for suppression,
6/// this stores counts for "did the total go up?" regression detection.
7///
8/// `schema_version` is the forward-compatibility gate; unknown fields are tolerated
9/// intentionally (see `CheckCounts` `#[serde(default)]`) so adding a new issue type
10/// stays backwards-compatible with existing baselines. Bumping `schema_version`
11/// signals "this baseline cannot be safely loaded by older fallow builds" and
12/// triggers a hard-fail with a regenerate hint in `load_regression_baseline`.
13#[derive(Debug, serde::Serialize, serde::Deserialize)]
14pub struct RegressionBaseline {
15    /// Schema version for forward compatibility.
16    pub schema_version: u32,
17    /// Fallow version that produced this baseline.
18    pub fallow_version: String,
19    /// ISO 8601 timestamp.
20    pub timestamp: String,
21    /// Git SHA at baseline time, if available.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub git_sha: Option<String>,
24    /// Dead code issue counts.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub check: Option<CheckCounts>,
27    /// Duplication counts.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub dupes: Option<DupesCounts>,
30}
31
32pub const REGRESSION_SCHEMA_VERSION: u32 = 1;
33
34/// Per-type issue counts for dead code analysis.
35///
36/// All fields use `#[serde(default)]` for forward compatibility: when fallow adds a new
37/// issue type, old baselines will deserialize with the new field defaulting to zero
38/// instead of failing.
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct CheckCounts {
41    #[serde(default)]
42    pub total_issues: usize,
43    #[serde(default)]
44    pub unused_files: usize,
45    #[serde(default)]
46    pub unused_exports: usize,
47    #[serde(default)]
48    pub unused_types: usize,
49    #[serde(default)]
50    pub unused_dependencies: usize,
51    #[serde(default)]
52    pub unused_dev_dependencies: usize,
53    #[serde(default)]
54    pub unused_optional_dependencies: usize,
55    #[serde(default)]
56    pub unused_enum_members: usize,
57    #[serde(default)]
58    pub unused_class_members: usize,
59    #[serde(default)]
60    pub unresolved_imports: usize,
61    #[serde(default)]
62    pub unlisted_dependencies: usize,
63    #[serde(default)]
64    pub duplicate_exports: usize,
65    #[serde(default)]
66    pub circular_dependencies: usize,
67    #[serde(default)]
68    pub re_export_cycles: usize,
69    #[serde(default)]
70    pub type_only_dependencies: usize,
71    #[serde(default)]
72    pub test_only_dependencies: usize,
73    #[serde(default)]
74    pub boundary_violations: usize,
75}
76
77impl CheckCounts {
78    #[must_use]
79    pub const fn from_results(results: &AnalysisResults) -> Self {
80        Self {
81            total_issues: results.total_issues(),
82            unused_files: results.unused_files.len(),
83            unused_exports: results.unused_exports.len(),
84            unused_types: results.unused_types.len(),
85            unused_dependencies: results.unused_dependencies.len(),
86            unused_dev_dependencies: results.unused_dev_dependencies.len(),
87            unused_optional_dependencies: results.unused_optional_dependencies.len(),
88            unused_enum_members: results.unused_enum_members.len(),
89            unused_class_members: results.unused_class_members.len(),
90            unresolved_imports: results.unresolved_imports.len(),
91            unlisted_dependencies: results.unlisted_dependencies.len(),
92            duplicate_exports: results.duplicate_exports.len(),
93            circular_dependencies: results.circular_dependencies.len(),
94            re_export_cycles: results.re_export_cycles.len(),
95            type_only_dependencies: results.type_only_dependencies.len(),
96            test_only_dependencies: results.test_only_dependencies.len(),
97            boundary_violations: results.boundary_violations.len(),
98        }
99    }
100
101    /// Convert from config-embedded baseline.
102    #[must_use]
103    pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
104        Self {
105            total_issues: b.total_issues,
106            unused_files: b.unused_files,
107            unused_exports: b.unused_exports,
108            unused_types: b.unused_types,
109            unused_dependencies: b.unused_dependencies,
110            unused_dev_dependencies: b.unused_dev_dependencies,
111            unused_optional_dependencies: b.unused_optional_dependencies,
112            unused_enum_members: b.unused_enum_members,
113            unused_class_members: b.unused_class_members,
114            unresolved_imports: b.unresolved_imports,
115            unlisted_dependencies: b.unlisted_dependencies,
116            duplicate_exports: b.duplicate_exports,
117            circular_dependencies: b.circular_dependencies,
118            re_export_cycles: b.re_export_cycles,
119            type_only_dependencies: b.type_only_dependencies,
120            test_only_dependencies: b.test_only_dependencies,
121            boundary_violations: b.boundary_violations,
122        }
123    }
124
125    /// Convert to config-embeddable baseline.
126    #[must_use]
127    pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
128        fallow_config::RegressionBaseline {
129            total_issues: self.total_issues,
130            unused_files: self.unused_files,
131            unused_exports: self.unused_exports,
132            unused_types: self.unused_types,
133            unused_dependencies: self.unused_dependencies,
134            unused_dev_dependencies: self.unused_dev_dependencies,
135            unused_optional_dependencies: self.unused_optional_dependencies,
136            unused_enum_members: self.unused_enum_members,
137            unused_class_members: self.unused_class_members,
138            unresolved_imports: self.unresolved_imports,
139            unlisted_dependencies: self.unlisted_dependencies,
140            duplicate_exports: self.duplicate_exports,
141            circular_dependencies: self.circular_dependencies,
142            re_export_cycles: self.re_export_cycles,
143            type_only_dependencies: self.type_only_dependencies,
144            test_only_dependencies: self.test_only_dependencies,
145            boundary_violations: self.boundary_violations,
146        }
147    }
148
149    /// Per-type deltas (current - baseline) for display. Only includes types with changes.
150    pub fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
151        let pairs: Vec<(&str, usize, usize)> = vec![
152            ("unused_files", self.unused_files, current.unused_files),
153            (
154                "unused_exports",
155                self.unused_exports,
156                current.unused_exports,
157            ),
158            ("unused_types", self.unused_types, current.unused_types),
159            (
160                "unused_dependencies",
161                self.unused_dependencies,
162                current.unused_dependencies,
163            ),
164            (
165                "unused_dev_dependencies",
166                self.unused_dev_dependencies,
167                current.unused_dev_dependencies,
168            ),
169            (
170                "unused_optional_dependencies",
171                self.unused_optional_dependencies,
172                current.unused_optional_dependencies,
173            ),
174            (
175                "unused_enum_members",
176                self.unused_enum_members,
177                current.unused_enum_members,
178            ),
179            (
180                "unused_class_members",
181                self.unused_class_members,
182                current.unused_class_members,
183            ),
184            (
185                "unresolved_imports",
186                self.unresolved_imports,
187                current.unresolved_imports,
188            ),
189            (
190                "unlisted_dependencies",
191                self.unlisted_dependencies,
192                current.unlisted_dependencies,
193            ),
194            (
195                "duplicate_exports",
196                self.duplicate_exports,
197                current.duplicate_exports,
198            ),
199            (
200                "circular_dependencies",
201                self.circular_dependencies,
202                current.circular_dependencies,
203            ),
204            (
205                "re_export_cycles",
206                self.re_export_cycles,
207                current.re_export_cycles,
208            ),
209            (
210                "type_only_dependencies",
211                self.type_only_dependencies,
212                current.type_only_dependencies,
213            ),
214            (
215                "test_only_dependencies",
216                self.test_only_dependencies,
217                current.test_only_dependencies,
218            ),
219            (
220                "boundary_violations",
221                self.boundary_violations,
222                current.boundary_violations,
223            ),
224        ];
225        pairs
226            .into_iter()
227            .filter_map(|(name, baseline, current)| {
228                let delta = current as isize - baseline as isize;
229                if delta != 0 {
230                    Some((name, delta))
231                } else {
232                    None
233                }
234            })
235            .collect()
236    }
237}
238
239/// Duplication counts for regression baseline.
240#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
241pub struct DupesCounts {
242    #[serde(default)]
243    pub clone_groups: usize,
244    #[serde(default)]
245    pub duplication_percentage: f64,
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use fallow_core::results::*;
252    use std::path::PathBuf;
253
254    #[test]
255    fn check_counts_from_results() {
256        let mut results = AnalysisResults::default();
257        results
258            .unused_files
259            .push(UnusedFileFinding::with_actions(UnusedFile {
260                path: PathBuf::from("a.ts"),
261            }));
262        results
263            .unused_exports
264            .push(UnusedExportFinding::with_actions(UnusedExport {
265                path: PathBuf::from("b.ts"),
266                export_name: "foo".into(),
267                is_type_only: false,
268                line: 1,
269                col: 0,
270                span_start: 0,
271                is_re_export: false,
272            }));
273        let counts = CheckCounts::from_results(&results);
274        assert_eq!(counts.total_issues, 2);
275        assert_eq!(counts.unused_files, 1);
276        assert_eq!(counts.unused_exports, 1);
277        assert_eq!(counts.unused_types, 0);
278    }
279
280    #[test]
281    fn deltas_reports_changes_only() {
282        let baseline = CheckCounts {
283            total_issues: 10,
284            unused_files: 5,
285            unused_exports: 3,
286            unused_types: 2,
287            unused_dependencies: 0,
288            unused_dev_dependencies: 0,
289            unused_optional_dependencies: 0,
290            unused_enum_members: 0,
291            unused_class_members: 0,
292            unresolved_imports: 0,
293            unlisted_dependencies: 0,
294            duplicate_exports: 0,
295            circular_dependencies: 0,
296            re_export_cycles: 0,
297            type_only_dependencies: 0,
298            test_only_dependencies: 0,
299            boundary_violations: 0,
300        };
301        let current = CheckCounts {
302            unused_files: 7,   // +2
303            unused_exports: 1, // -2
304            unused_types: 2,   // 0 (no change)
305            ..baseline
306        };
307        let deltas = baseline.deltas(&current);
308        assert_eq!(deltas.len(), 2);
309        assert!(deltas.contains(&("unused_files", 2)));
310        assert!(deltas.contains(&("unused_exports", -2)));
311    }
312
313    #[test]
314    fn regression_baseline_roundtrip() {
315        let baseline = RegressionBaseline {
316            schema_version: 1,
317            fallow_version: "2.4.0".into(),
318            timestamp: "2026-03-27T10:00:00Z".into(),
319            git_sha: Some("abc123".into()),
320            check: Some(CheckCounts {
321                total_issues: 42,
322                unused_files: 5,
323                unused_exports: 20,
324                unused_types: 8,
325                unused_dependencies: 3,
326                unused_dev_dependencies: 2,
327                unused_optional_dependencies: 0,
328                unused_enum_members: 1,
329                unused_class_members: 1,
330                unresolved_imports: 0,
331                unlisted_dependencies: 1,
332                duplicate_exports: 0,
333                circular_dependencies: 1,
334                re_export_cycles: 0,
335                type_only_dependencies: 0,
336                test_only_dependencies: 0,
337                boundary_violations: 0,
338            }),
339            dupes: Some(DupesCounts {
340                clone_groups: 12,
341                duplication_percentage: 4.2,
342            }),
343        };
344        let json = serde_json::to_string_pretty(&baseline).unwrap();
345        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
346        assert_eq!(loaded.schema_version, 1);
347        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
348        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
349    }
350
351    #[test]
352    fn check_counts_config_roundtrip() {
353        let counts = CheckCounts {
354            total_issues: 42,
355            unused_files: 5,
356            unused_exports: 20,
357            unused_types: 8,
358            unused_dependencies: 3,
359            unused_dev_dependencies: 2,
360            unused_optional_dependencies: 1,
361            unused_enum_members: 1,
362            unused_class_members: 1,
363            unresolved_imports: 0,
364            unlisted_dependencies: 1,
365            duplicate_exports: 0,
366            circular_dependencies: 0,
367            re_export_cycles: 0,
368            type_only_dependencies: 0,
369            test_only_dependencies: 0,
370            boundary_violations: 0,
371        };
372        let config_baseline = counts.to_config_baseline();
373        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
374        assert_eq!(roundtripped.total_issues, 42);
375        assert_eq!(roundtripped.unused_files, 5);
376        assert_eq!(roundtripped.unused_exports, 20);
377        assert_eq!(roundtripped.unused_types, 8);
378        assert_eq!(roundtripped.unused_dependencies, 3);
379        assert_eq!(roundtripped.unused_dev_dependencies, 2);
380        assert_eq!(roundtripped.unused_optional_dependencies, 1);
381        assert_eq!(roundtripped.unused_enum_members, 1);
382        assert_eq!(roundtripped.unused_class_members, 1);
383        assert_eq!(roundtripped.unresolved_imports, 0);
384        assert_eq!(roundtripped.unlisted_dependencies, 1);
385        assert_eq!(roundtripped.duplicate_exports, 0);
386        assert_eq!(roundtripped.circular_dependencies, 0);
387        assert_eq!(roundtripped.type_only_dependencies, 0);
388        assert_eq!(roundtripped.test_only_dependencies, 0);
389    }
390
391    #[test]
392    fn check_counts_zero_config_roundtrip() {
393        let counts = CheckCounts {
394            total_issues: 0,
395            unused_files: 0,
396            unused_exports: 0,
397            unused_types: 0,
398            unused_dependencies: 0,
399            unused_dev_dependencies: 0,
400            unused_optional_dependencies: 0,
401            unused_enum_members: 0,
402            unused_class_members: 0,
403            unresolved_imports: 0,
404            unlisted_dependencies: 0,
405            duplicate_exports: 0,
406            circular_dependencies: 0,
407            re_export_cycles: 0,
408            type_only_dependencies: 0,
409            test_only_dependencies: 0,
410            boundary_violations: 0,
411        };
412        let config_baseline = counts.to_config_baseline();
413        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
414        assert_eq!(roundtripped.total_issues, 0);
415        assert_eq!(roundtripped.unused_files, 0);
416    }
417
418    #[test]
419    fn deltas_empty_when_identical() {
420        let counts = CheckCounts {
421            total_issues: 10,
422            unused_files: 5,
423            unused_exports: 3,
424            unused_types: 2,
425            unused_dependencies: 0,
426            unused_dev_dependencies: 0,
427            unused_optional_dependencies: 0,
428            unused_enum_members: 0,
429            unused_class_members: 0,
430            unresolved_imports: 0,
431            unlisted_dependencies: 0,
432            duplicate_exports: 0,
433            circular_dependencies: 0,
434            re_export_cycles: 0,
435            type_only_dependencies: 0,
436            test_only_dependencies: 0,
437            boundary_violations: 0,
438        };
439        let deltas = counts.deltas(&counts);
440        assert!(deltas.is_empty());
441    }
442
443    #[test]
444    fn deltas_all_categories_changed() {
445        let baseline = CheckCounts {
446            total_issues: 0,
447            unused_files: 0,
448            unused_exports: 0,
449            unused_types: 0,
450            unused_dependencies: 0,
451            unused_dev_dependencies: 0,
452            unused_optional_dependencies: 0,
453            unused_enum_members: 0,
454            unused_class_members: 0,
455            unresolved_imports: 0,
456            unlisted_dependencies: 0,
457            duplicate_exports: 0,
458            circular_dependencies: 0,
459            re_export_cycles: 0,
460            type_only_dependencies: 0,
461            test_only_dependencies: 0,
462            boundary_violations: 0,
463        };
464        let current = CheckCounts {
465            total_issues: 14,
466            unused_files: 1,
467            unused_exports: 1,
468            unused_types: 1,
469            unused_dependencies: 1,
470            unused_dev_dependencies: 1,
471            unused_optional_dependencies: 1,
472            unused_enum_members: 1,
473            unused_class_members: 1,
474            unresolved_imports: 1,
475            unlisted_dependencies: 1,
476            duplicate_exports: 1,
477            circular_dependencies: 1,
478            re_export_cycles: 0,
479            type_only_dependencies: 1,
480            test_only_dependencies: 1,
481            boundary_violations: 1,
482        };
483        let deltas = baseline.deltas(&current);
484        assert_eq!(deltas.len(), 15);
485        for (_, d) in &deltas {
486            assert_eq!(*d, 1);
487        }
488    }
489
490    #[test]
491    fn deltas_mixed_increase_decrease() {
492        let baseline = CheckCounts {
493            total_issues: 10,
494            unused_files: 5,
495            unused_exports: 3,
496            unused_types: 2,
497            unused_dependencies: 0,
498            unused_dev_dependencies: 0,
499            unused_optional_dependencies: 0,
500            unused_enum_members: 0,
501            unused_class_members: 0,
502            unresolved_imports: 0,
503            unlisted_dependencies: 0,
504            duplicate_exports: 0,
505            circular_dependencies: 0,
506            re_export_cycles: 0,
507            type_only_dependencies: 0,
508            test_only_dependencies: 0,
509            boundary_violations: 0,
510        };
511        let current = CheckCounts {
512            unused_files: 3,
513            unused_exports: 5,
514            unused_types: 0,
515            unresolved_imports: 1,
516            ..baseline
517        };
518        let deltas = baseline.deltas(&current);
519        assert_eq!(deltas.len(), 4);
520        assert!(deltas.contains(&("unused_files", -2)));
521        assert!(deltas.contains(&("unused_exports", 2)));
522        assert!(deltas.contains(&("unused_types", -2)));
523        assert!(deltas.contains(&("unresolved_imports", 1)));
524    }
525
526    #[test]
527    fn dupes_counts_roundtrip() {
528        let dupes = DupesCounts {
529            clone_groups: 8,
530            duplication_percentage: 3.17,
531        };
532        let json = serde_json::to_string(&dupes).unwrap();
533        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
534        assert_eq!(loaded.clone_groups, 8);
535        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
536    }
537
538    #[test]
539    fn dupes_counts_default_fields() {
540        let json = "{}";
541        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
542        assert_eq!(loaded.clone_groups, 0);
543        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
544    }
545
546    #[test]
547    fn baseline_without_check_section() {
548        let baseline = RegressionBaseline {
549            schema_version: 1,
550            fallow_version: "2.4.0".into(),
551            timestamp: "2026-03-27T10:00:00Z".into(),
552            git_sha: None,
553            check: None,
554            dupes: Some(DupesCounts {
555                clone_groups: 3,
556                duplication_percentage: 1.0,
557            }),
558        };
559        let json = serde_json::to_string_pretty(&baseline).unwrap();
560        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
561        assert!(loaded.check.is_none());
562        assert!(loaded.dupes.is_some());
563    }
564
565    #[test]
566    fn baseline_without_dupes_section() {
567        let baseline = RegressionBaseline {
568            schema_version: 1,
569            fallow_version: "2.4.0".into(),
570            timestamp: "2026-03-27T10:00:00Z".into(),
571            git_sha: Some("deadbeef".into()),
572            check: Some(CheckCounts {
573                total_issues: 1,
574                unused_files: 1,
575                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
576            }),
577            dupes: None,
578        };
579        let json = serde_json::to_string_pretty(&baseline).unwrap();
580        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
581        assert!(loaded.check.is_some());
582        assert!(loaded.dupes.is_none());
583        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
584    }
585
586    #[test]
587    fn baseline_without_git_sha() {
588        let baseline = RegressionBaseline {
589            schema_version: 1,
590            fallow_version: "2.4.0".into(),
591            timestamp: "2026-03-27T10:00:00Z".into(),
592            git_sha: None,
593            check: None,
594            dupes: None,
595        };
596        let json = serde_json::to_string_pretty(&baseline).unwrap();
597        assert!(!json.contains("git_sha"));
598        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
599        assert!(loaded.git_sha.is_none());
600    }
601
602    #[test]
603    fn baseline_json_with_unknown_check_fields_deserializes() {
604        let json = r#"{
605            "schema_version": 1,
606            "fallow_version": "3.0.0",
607            "timestamp": "2026-03-27T10:00:00Z",
608            "check": {
609                "total_issues": 10,
610                "unused_files": 2,
611                "some_future_field": 99
612            }
613        }"#;
614        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
615        assert!(loaded.is_ok());
616        let loaded = loaded.unwrap();
617        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
618    }
619}