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    #[serde(default)]
76    pub boundary_coverage_violations: usize,
77    #[serde(default)]
78    pub boundary_call_violations: usize,
79    #[serde(default)]
80    pub policy_violations: usize,
81}
82
83impl CheckCounts {
84    #[must_use]
85    pub const fn from_results(results: &AnalysisResults) -> Self {
86        Self {
87            total_issues: results.total_issues(),
88            unused_files: results.unused_files.len(),
89            unused_exports: results.unused_exports.len(),
90            unused_types: results.unused_types.len(),
91            unused_dependencies: results.unused_dependencies.len(),
92            unused_dev_dependencies: results.unused_dev_dependencies.len(),
93            unused_optional_dependencies: results.unused_optional_dependencies.len(),
94            unused_enum_members: results.unused_enum_members.len(),
95            unused_class_members: results.unused_class_members.len(),
96            unresolved_imports: results.unresolved_imports.len(),
97            unlisted_dependencies: results.unlisted_dependencies.len(),
98            duplicate_exports: results.duplicate_exports.len(),
99            circular_dependencies: results.circular_dependencies.len(),
100            re_export_cycles: results.re_export_cycles.len(),
101            type_only_dependencies: results.type_only_dependencies.len(),
102            test_only_dependencies: results.test_only_dependencies.len(),
103            boundary_violations: results.boundary_violations.len(),
104            boundary_coverage_violations: results.boundary_coverage_violations.len(),
105            boundary_call_violations: results.boundary_call_violations.len(),
106            policy_violations: results.policy_violations.len(),
107        }
108    }
109
110    /// Convert from config-embedded baseline.
111    #[must_use]
112    pub const fn from_config_baseline(b: &fallow_config::RegressionBaseline) -> Self {
113        Self {
114            total_issues: b.total_issues,
115            unused_files: b.unused_files,
116            unused_exports: b.unused_exports,
117            unused_types: b.unused_types,
118            unused_dependencies: b.unused_dependencies,
119            unused_dev_dependencies: b.unused_dev_dependencies,
120            unused_optional_dependencies: b.unused_optional_dependencies,
121            unused_enum_members: b.unused_enum_members,
122            unused_class_members: b.unused_class_members,
123            unresolved_imports: b.unresolved_imports,
124            unlisted_dependencies: b.unlisted_dependencies,
125            duplicate_exports: b.duplicate_exports,
126            circular_dependencies: b.circular_dependencies,
127            re_export_cycles: b.re_export_cycles,
128            type_only_dependencies: b.type_only_dependencies,
129            test_only_dependencies: b.test_only_dependencies,
130            boundary_violations: b.boundary_violations,
131            boundary_coverage_violations: b.boundary_coverage_violations,
132            boundary_call_violations: b.boundary_call_violations,
133            policy_violations: b.policy_violations,
134        }
135    }
136
137    /// Convert to config-embeddable baseline.
138    #[must_use]
139    pub const fn to_config_baseline(&self) -> fallow_config::RegressionBaseline {
140        fallow_config::RegressionBaseline {
141            total_issues: self.total_issues,
142            unused_files: self.unused_files,
143            unused_exports: self.unused_exports,
144            unused_types: self.unused_types,
145            unused_dependencies: self.unused_dependencies,
146            unused_dev_dependencies: self.unused_dev_dependencies,
147            unused_optional_dependencies: self.unused_optional_dependencies,
148            unused_enum_members: self.unused_enum_members,
149            unused_class_members: self.unused_class_members,
150            unresolved_imports: self.unresolved_imports,
151            unlisted_dependencies: self.unlisted_dependencies,
152            duplicate_exports: self.duplicate_exports,
153            circular_dependencies: self.circular_dependencies,
154            re_export_cycles: self.re_export_cycles,
155            type_only_dependencies: self.type_only_dependencies,
156            test_only_dependencies: self.test_only_dependencies,
157            boundary_violations: self.boundary_violations,
158            boundary_coverage_violations: self.boundary_coverage_violations,
159            boundary_call_violations: self.boundary_call_violations,
160            policy_violations: self.policy_violations,
161        }
162    }
163
164    /// Per-type deltas (current - baseline) for display. Only includes types with changes.
165    pub fn deltas(&self, current: &Self) -> Vec<(&'static str, isize)> {
166        let pairs: Vec<(&str, usize, usize)> = vec![
167            ("unused_files", self.unused_files, current.unused_files),
168            (
169                "unused_exports",
170                self.unused_exports,
171                current.unused_exports,
172            ),
173            ("unused_types", self.unused_types, current.unused_types),
174            (
175                "unused_dependencies",
176                self.unused_dependencies,
177                current.unused_dependencies,
178            ),
179            (
180                "unused_dev_dependencies",
181                self.unused_dev_dependencies,
182                current.unused_dev_dependencies,
183            ),
184            (
185                "unused_optional_dependencies",
186                self.unused_optional_dependencies,
187                current.unused_optional_dependencies,
188            ),
189            (
190                "unused_enum_members",
191                self.unused_enum_members,
192                current.unused_enum_members,
193            ),
194            (
195                "unused_class_members",
196                self.unused_class_members,
197                current.unused_class_members,
198            ),
199            (
200                "unresolved_imports",
201                self.unresolved_imports,
202                current.unresolved_imports,
203            ),
204            (
205                "unlisted_dependencies",
206                self.unlisted_dependencies,
207                current.unlisted_dependencies,
208            ),
209            (
210                "duplicate_exports",
211                self.duplicate_exports,
212                current.duplicate_exports,
213            ),
214            (
215                "circular_dependencies",
216                self.circular_dependencies,
217                current.circular_dependencies,
218            ),
219            (
220                "re_export_cycles",
221                self.re_export_cycles,
222                current.re_export_cycles,
223            ),
224            (
225                "type_only_dependencies",
226                self.type_only_dependencies,
227                current.type_only_dependencies,
228            ),
229            (
230                "test_only_dependencies",
231                self.test_only_dependencies,
232                current.test_only_dependencies,
233            ),
234            (
235                "boundary_violations",
236                self.boundary_violations,
237                current.boundary_violations,
238            ),
239            (
240                "boundary_coverage_violations",
241                self.boundary_coverage_violations,
242                current.boundary_coverage_violations,
243            ),
244            (
245                "boundary_call_violations",
246                self.boundary_call_violations,
247                current.boundary_call_violations,
248            ),
249            (
250                "policy_violations",
251                self.policy_violations,
252                current.policy_violations,
253            ),
254        ];
255        pairs
256            .into_iter()
257            .filter_map(|(name, baseline, current)| {
258                let delta = current as isize - baseline as isize;
259                if delta != 0 {
260                    Some((name, delta))
261                } else {
262                    None
263                }
264            })
265            .collect()
266    }
267}
268
269/// Duplication counts for regression baseline.
270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
271pub struct DupesCounts {
272    #[serde(default)]
273    pub clone_groups: usize,
274    #[serde(default)]
275    pub duplication_percentage: f64,
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use fallow_core::results::*;
282    use std::path::PathBuf;
283
284    #[test]
285    fn check_counts_from_results() {
286        let mut results = AnalysisResults::default();
287        results
288            .unused_files
289            .push(UnusedFileFinding::with_actions(UnusedFile {
290                path: PathBuf::from("a.ts"),
291            }));
292        results
293            .unused_exports
294            .push(UnusedExportFinding::with_actions(UnusedExport {
295                path: PathBuf::from("b.ts"),
296                export_name: "foo".into(),
297                is_type_only: false,
298                line: 1,
299                col: 0,
300                span_start: 0,
301                is_re_export: false,
302            }));
303        let counts = CheckCounts::from_results(&results);
304        assert_eq!(counts.total_issues, 2);
305        assert_eq!(counts.unused_files, 1);
306        assert_eq!(counts.unused_exports, 1);
307        assert_eq!(counts.unused_types, 0);
308    }
309
310    #[test]
311    fn deltas_reports_changes_only() {
312        let baseline = CheckCounts {
313            total_issues: 10,
314            unused_files: 5,
315            unused_exports: 3,
316            unused_types: 2,
317            unused_dependencies: 0,
318            unused_dev_dependencies: 0,
319            unused_optional_dependencies: 0,
320            unused_enum_members: 0,
321            unused_class_members: 0,
322            unresolved_imports: 0,
323            unlisted_dependencies: 0,
324            duplicate_exports: 0,
325            circular_dependencies: 0,
326            re_export_cycles: 0,
327            type_only_dependencies: 0,
328            test_only_dependencies: 0,
329            boundary_violations: 0,
330            boundary_coverage_violations: 0,
331            boundary_call_violations: 0,
332            policy_violations: 0,
333        };
334        let current = CheckCounts {
335            unused_files: 7,   // +2
336            unused_exports: 1, // -2
337            unused_types: 2,   // 0 (no change)
338            ..baseline
339        };
340        let deltas = baseline.deltas(&current);
341        assert_eq!(deltas.len(), 2);
342        assert!(deltas.contains(&("unused_files", 2)));
343        assert!(deltas.contains(&("unused_exports", -2)));
344    }
345
346    #[test]
347    fn regression_baseline_roundtrip() {
348        let baseline = RegressionBaseline {
349            schema_version: 1,
350            fallow_version: "2.4.0".into(),
351            timestamp: "2026-03-27T10:00:00Z".into(),
352            git_sha: Some("abc123".into()),
353            check: Some(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: 0,
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: 1,
367                re_export_cycles: 0,
368                type_only_dependencies: 0,
369                test_only_dependencies: 0,
370                boundary_violations: 0,
371                boundary_coverage_violations: 0,
372                boundary_call_violations: 0,
373                policy_violations: 0,
374            }),
375            dupes: Some(DupesCounts {
376                clone_groups: 12,
377                duplication_percentage: 4.2,
378            }),
379        };
380        let json = serde_json::to_string_pretty(&baseline).unwrap();
381        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
382        assert_eq!(loaded.schema_version, 1);
383        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
384        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
385    }
386
387    #[test]
388    fn check_counts_config_roundtrip() {
389        let counts = CheckCounts {
390            total_issues: 42,
391            unused_files: 5,
392            unused_exports: 20,
393            unused_types: 8,
394            unused_dependencies: 3,
395            unused_dev_dependencies: 2,
396            unused_optional_dependencies: 1,
397            unused_enum_members: 1,
398            unused_class_members: 1,
399            unresolved_imports: 0,
400            unlisted_dependencies: 1,
401            duplicate_exports: 0,
402            circular_dependencies: 0,
403            re_export_cycles: 0,
404            type_only_dependencies: 0,
405            test_only_dependencies: 0,
406            boundary_violations: 0,
407            boundary_coverage_violations: 0,
408            boundary_call_violations: 0,
409            policy_violations: 0,
410        };
411        let config_baseline = counts.to_config_baseline();
412        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
413        assert_eq!(roundtripped.total_issues, 42);
414        assert_eq!(roundtripped.unused_files, 5);
415        assert_eq!(roundtripped.unused_exports, 20);
416        assert_eq!(roundtripped.unused_types, 8);
417        assert_eq!(roundtripped.unused_dependencies, 3);
418        assert_eq!(roundtripped.unused_dev_dependencies, 2);
419        assert_eq!(roundtripped.unused_optional_dependencies, 1);
420        assert_eq!(roundtripped.unused_enum_members, 1);
421        assert_eq!(roundtripped.unused_class_members, 1);
422        assert_eq!(roundtripped.unresolved_imports, 0);
423        assert_eq!(roundtripped.unlisted_dependencies, 1);
424        assert_eq!(roundtripped.duplicate_exports, 0);
425        assert_eq!(roundtripped.circular_dependencies, 0);
426        assert_eq!(roundtripped.type_only_dependencies, 0);
427        assert_eq!(roundtripped.test_only_dependencies, 0);
428    }
429
430    #[test]
431    fn check_counts_zero_config_roundtrip() {
432        let counts = CheckCounts {
433            total_issues: 0,
434            unused_files: 0,
435            unused_exports: 0,
436            unused_types: 0,
437            unused_dependencies: 0,
438            unused_dev_dependencies: 0,
439            unused_optional_dependencies: 0,
440            unused_enum_members: 0,
441            unused_class_members: 0,
442            unresolved_imports: 0,
443            unlisted_dependencies: 0,
444            duplicate_exports: 0,
445            circular_dependencies: 0,
446            re_export_cycles: 0,
447            type_only_dependencies: 0,
448            test_only_dependencies: 0,
449            boundary_violations: 0,
450            boundary_coverage_violations: 0,
451            boundary_call_violations: 0,
452            policy_violations: 0,
453        };
454        let config_baseline = counts.to_config_baseline();
455        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
456        assert_eq!(roundtripped.total_issues, 0);
457        assert_eq!(roundtripped.unused_files, 0);
458    }
459
460    #[test]
461    fn deltas_empty_when_identical() {
462        let counts = CheckCounts {
463            total_issues: 10,
464            unused_files: 5,
465            unused_exports: 3,
466            unused_types: 2,
467            unused_dependencies: 0,
468            unused_dev_dependencies: 0,
469            unused_optional_dependencies: 0,
470            unused_enum_members: 0,
471            unused_class_members: 0,
472            unresolved_imports: 0,
473            unlisted_dependencies: 0,
474            duplicate_exports: 0,
475            circular_dependencies: 0,
476            re_export_cycles: 0,
477            type_only_dependencies: 0,
478            test_only_dependencies: 0,
479            boundary_violations: 0,
480            boundary_coverage_violations: 0,
481            boundary_call_violations: 0,
482            policy_violations: 0,
483        };
484        let deltas = counts.deltas(&counts);
485        assert!(deltas.is_empty());
486    }
487
488    #[test]
489    fn deltas_all_categories_changed() {
490        let baseline = CheckCounts {
491            total_issues: 0,
492            unused_files: 0,
493            unused_exports: 0,
494            unused_types: 0,
495            unused_dependencies: 0,
496            unused_dev_dependencies: 0,
497            unused_optional_dependencies: 0,
498            unused_enum_members: 0,
499            unused_class_members: 0,
500            unresolved_imports: 0,
501            unlisted_dependencies: 0,
502            duplicate_exports: 0,
503            circular_dependencies: 0,
504            re_export_cycles: 0,
505            type_only_dependencies: 0,
506            test_only_dependencies: 0,
507            boundary_violations: 0,
508            boundary_coverage_violations: 0,
509            boundary_call_violations: 0,
510            policy_violations: 0,
511        };
512        let current = CheckCounts {
513            total_issues: 14,
514            unused_files: 1,
515            unused_exports: 1,
516            unused_types: 1,
517            unused_dependencies: 1,
518            unused_dev_dependencies: 1,
519            unused_optional_dependencies: 1,
520            unused_enum_members: 1,
521            unused_class_members: 1,
522            unresolved_imports: 1,
523            unlisted_dependencies: 1,
524            duplicate_exports: 1,
525            circular_dependencies: 1,
526            re_export_cycles: 0,
527            type_only_dependencies: 1,
528            test_only_dependencies: 1,
529            boundary_violations: 1,
530            boundary_coverage_violations: 0,
531            boundary_call_violations: 0,
532            policy_violations: 0,
533        };
534        let deltas = baseline.deltas(&current);
535        assert_eq!(deltas.len(), 15);
536        for (_, d) in &deltas {
537            assert_eq!(*d, 1);
538        }
539    }
540
541    #[test]
542    fn deltas_mixed_increase_decrease() {
543        let baseline = CheckCounts {
544            total_issues: 10,
545            unused_files: 5,
546            unused_exports: 3,
547            unused_types: 2,
548            unused_dependencies: 0,
549            unused_dev_dependencies: 0,
550            unused_optional_dependencies: 0,
551            unused_enum_members: 0,
552            unused_class_members: 0,
553            unresolved_imports: 0,
554            unlisted_dependencies: 0,
555            duplicate_exports: 0,
556            circular_dependencies: 0,
557            re_export_cycles: 0,
558            type_only_dependencies: 0,
559            test_only_dependencies: 0,
560            boundary_violations: 0,
561            boundary_coverage_violations: 0,
562            boundary_call_violations: 0,
563            policy_violations: 0,
564        };
565        let current = CheckCounts {
566            unused_files: 3,
567            unused_exports: 5,
568            unused_types: 0,
569            unresolved_imports: 1,
570            ..baseline
571        };
572        let deltas = baseline.deltas(&current);
573        assert_eq!(deltas.len(), 4);
574        assert!(deltas.contains(&("unused_files", -2)));
575        assert!(deltas.contains(&("unused_exports", 2)));
576        assert!(deltas.contains(&("unused_types", -2)));
577        assert!(deltas.contains(&("unresolved_imports", 1)));
578    }
579
580    #[test]
581    fn dupes_counts_roundtrip() {
582        let dupes = DupesCounts {
583            clone_groups: 8,
584            duplication_percentage: 3.17,
585        };
586        let json = serde_json::to_string(&dupes).unwrap();
587        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
588        assert_eq!(loaded.clone_groups, 8);
589        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
590    }
591
592    #[test]
593    fn dupes_counts_default_fields() {
594        let json = "{}";
595        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
596        assert_eq!(loaded.clone_groups, 0);
597        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
598    }
599
600    #[test]
601    fn baseline_without_check_section() {
602        let baseline = RegressionBaseline {
603            schema_version: 1,
604            fallow_version: "2.4.0".into(),
605            timestamp: "2026-03-27T10:00:00Z".into(),
606            git_sha: None,
607            check: None,
608            dupes: Some(DupesCounts {
609                clone_groups: 3,
610                duplication_percentage: 1.0,
611            }),
612        };
613        let json = serde_json::to_string_pretty(&baseline).unwrap();
614        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
615        assert!(loaded.check.is_none());
616        assert!(loaded.dupes.is_some());
617    }
618
619    #[test]
620    fn baseline_without_dupes_section() {
621        let baseline = RegressionBaseline {
622            schema_version: 1,
623            fallow_version: "2.4.0".into(),
624            timestamp: "2026-03-27T10:00:00Z".into(),
625            git_sha: Some("deadbeef".into()),
626            check: Some(CheckCounts {
627                total_issues: 1,
628                unused_files: 1,
629                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
630            }),
631            dupes: None,
632        };
633        let json = serde_json::to_string_pretty(&baseline).unwrap();
634        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
635        assert!(loaded.check.is_some());
636        assert!(loaded.dupes.is_none());
637        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
638    }
639
640    #[test]
641    fn baseline_without_git_sha() {
642        let baseline = RegressionBaseline {
643            schema_version: 1,
644            fallow_version: "2.4.0".into(),
645            timestamp: "2026-03-27T10:00:00Z".into(),
646            git_sha: None,
647            check: None,
648            dupes: None,
649        };
650        let json = serde_json::to_string_pretty(&baseline).unwrap();
651        assert!(!json.contains("git_sha"));
652        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
653        assert!(loaded.git_sha.is_none());
654    }
655
656    #[test]
657    fn baseline_json_with_unknown_check_fields_deserializes() {
658        let json = r#"{
659            "schema_version": 1,
660            "fallow_version": "3.0.0",
661            "timestamp": "2026-03-27T10:00:00Z",
662            "check": {
663                "total_issues": 10,
664                "unused_files": 2,
665                "some_future_field": 99
666            }
667        }"#;
668        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
669        assert!(loaded.is_ok());
670        let loaded = loaded.unwrap();
671        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
672    }
673}