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 mut deltas = Vec::new();
167        macro_rules! push_delta {
168            ($field:ident) => {
169                push_count_delta(&mut deltas, stringify!($field), self.$field, current.$field);
170            };
171        }
172
173        push_delta!(unused_files);
174        push_delta!(unused_exports);
175        push_delta!(unused_types);
176        push_delta!(unused_dependencies);
177        push_delta!(unused_dev_dependencies);
178        push_delta!(unused_optional_dependencies);
179        push_delta!(unused_enum_members);
180        push_delta!(unused_class_members);
181        push_delta!(unresolved_imports);
182        push_delta!(unlisted_dependencies);
183        push_delta!(duplicate_exports);
184        push_delta!(circular_dependencies);
185        push_delta!(re_export_cycles);
186        push_delta!(type_only_dependencies);
187        push_delta!(test_only_dependencies);
188        push_delta!(boundary_violations);
189        push_delta!(boundary_coverage_violations);
190        push_delta!(boundary_call_violations);
191        push_delta!(policy_violations);
192        deltas
193    }
194}
195
196fn push_count_delta(
197    deltas: &mut Vec<(&'static str, isize)>,
198    name: &'static str,
199    baseline: usize,
200    current: usize,
201) {
202    let delta = current as isize - baseline as isize;
203    if delta != 0 {
204        deltas.push((name, delta));
205    }
206}
207
208/// Duplication counts for regression baseline.
209#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
210pub struct DupesCounts {
211    #[serde(default)]
212    pub clone_groups: usize,
213    #[serde(default)]
214    pub duplication_percentage: f64,
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use fallow_core::results::*;
221    use std::path::PathBuf;
222
223    #[test]
224    fn check_counts_from_results() {
225        let mut results = AnalysisResults::default();
226        results
227            .unused_files
228            .push(UnusedFileFinding::with_actions(UnusedFile {
229                path: PathBuf::from("a.ts"),
230            }));
231        results
232            .unused_exports
233            .push(UnusedExportFinding::with_actions(UnusedExport {
234                path: PathBuf::from("b.ts"),
235                export_name: "foo".into(),
236                is_type_only: false,
237                line: 1,
238                col: 0,
239                span_start: 0,
240                is_re_export: false,
241            }));
242        let counts = CheckCounts::from_results(&results);
243        assert_eq!(counts.total_issues, 2);
244        assert_eq!(counts.unused_files, 1);
245        assert_eq!(counts.unused_exports, 1);
246        assert_eq!(counts.unused_types, 0);
247    }
248
249    #[test]
250    fn deltas_reports_changes_only() {
251        let baseline = CheckCounts {
252            total_issues: 10,
253            unused_files: 5,
254            unused_exports: 3,
255            unused_types: 2,
256            unused_dependencies: 0,
257            unused_dev_dependencies: 0,
258            unused_optional_dependencies: 0,
259            unused_enum_members: 0,
260            unused_class_members: 0,
261            unresolved_imports: 0,
262            unlisted_dependencies: 0,
263            duplicate_exports: 0,
264            circular_dependencies: 0,
265            re_export_cycles: 0,
266            type_only_dependencies: 0,
267            test_only_dependencies: 0,
268            boundary_violations: 0,
269            boundary_coverage_violations: 0,
270            boundary_call_violations: 0,
271            policy_violations: 0,
272        };
273        let current = CheckCounts {
274            unused_files: 7,   // +2
275            unused_exports: 1, // -2
276            unused_types: 2,   // 0 (no change)
277            ..baseline
278        };
279        let deltas = baseline.deltas(&current);
280        assert_eq!(deltas.len(), 2);
281        assert!(deltas.contains(&("unused_files", 2)));
282        assert!(deltas.contains(&("unused_exports", -2)));
283    }
284
285    #[test]
286    fn regression_baseline_roundtrip() {
287        let baseline = RegressionBaseline {
288            schema_version: 1,
289            fallow_version: "2.4.0".into(),
290            timestamp: "2026-03-27T10:00:00Z".into(),
291            git_sha: Some("abc123".into()),
292            check: Some(CheckCounts {
293                total_issues: 42,
294                unused_files: 5,
295                unused_exports: 20,
296                unused_types: 8,
297                unused_dependencies: 3,
298                unused_dev_dependencies: 2,
299                unused_optional_dependencies: 0,
300                unused_enum_members: 1,
301                unused_class_members: 1,
302                unresolved_imports: 0,
303                unlisted_dependencies: 1,
304                duplicate_exports: 0,
305                circular_dependencies: 1,
306                re_export_cycles: 0,
307                type_only_dependencies: 0,
308                test_only_dependencies: 0,
309                boundary_violations: 0,
310                boundary_coverage_violations: 0,
311                boundary_call_violations: 0,
312                policy_violations: 0,
313            }),
314            dupes: Some(DupesCounts {
315                clone_groups: 12,
316                duplication_percentage: 4.2,
317            }),
318        };
319        let json = serde_json::to_string_pretty(&baseline).unwrap();
320        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
321        assert_eq!(loaded.schema_version, 1);
322        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 42);
323        assert_eq!(loaded.dupes.as_ref().unwrap().clone_groups, 12);
324    }
325
326    #[test]
327    fn check_counts_config_roundtrip() {
328        let counts = CheckCounts {
329            total_issues: 42,
330            unused_files: 5,
331            unused_exports: 20,
332            unused_types: 8,
333            unused_dependencies: 3,
334            unused_dev_dependencies: 2,
335            unused_optional_dependencies: 1,
336            unused_enum_members: 1,
337            unused_class_members: 1,
338            unresolved_imports: 0,
339            unlisted_dependencies: 1,
340            duplicate_exports: 0,
341            circular_dependencies: 0,
342            re_export_cycles: 0,
343            type_only_dependencies: 0,
344            test_only_dependencies: 0,
345            boundary_violations: 0,
346            boundary_coverage_violations: 0,
347            boundary_call_violations: 0,
348            policy_violations: 0,
349        };
350        let config_baseline = counts.to_config_baseline();
351        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
352        assert_eq!(roundtripped.total_issues, 42);
353        assert_eq!(roundtripped.unused_files, 5);
354        assert_eq!(roundtripped.unused_exports, 20);
355        assert_eq!(roundtripped.unused_types, 8);
356        assert_eq!(roundtripped.unused_dependencies, 3);
357        assert_eq!(roundtripped.unused_dev_dependencies, 2);
358        assert_eq!(roundtripped.unused_optional_dependencies, 1);
359        assert_eq!(roundtripped.unused_enum_members, 1);
360        assert_eq!(roundtripped.unused_class_members, 1);
361        assert_eq!(roundtripped.unresolved_imports, 0);
362        assert_eq!(roundtripped.unlisted_dependencies, 1);
363        assert_eq!(roundtripped.duplicate_exports, 0);
364        assert_eq!(roundtripped.circular_dependencies, 0);
365        assert_eq!(roundtripped.type_only_dependencies, 0);
366        assert_eq!(roundtripped.test_only_dependencies, 0);
367    }
368
369    #[test]
370    fn check_counts_zero_config_roundtrip() {
371        let counts = CheckCounts {
372            total_issues: 0,
373            unused_files: 0,
374            unused_exports: 0,
375            unused_types: 0,
376            unused_dependencies: 0,
377            unused_dev_dependencies: 0,
378            unused_optional_dependencies: 0,
379            unused_enum_members: 0,
380            unused_class_members: 0,
381            unresolved_imports: 0,
382            unlisted_dependencies: 0,
383            duplicate_exports: 0,
384            circular_dependencies: 0,
385            re_export_cycles: 0,
386            type_only_dependencies: 0,
387            test_only_dependencies: 0,
388            boundary_violations: 0,
389            boundary_coverage_violations: 0,
390            boundary_call_violations: 0,
391            policy_violations: 0,
392        };
393        let config_baseline = counts.to_config_baseline();
394        let roundtripped = CheckCounts::from_config_baseline(&config_baseline);
395        assert_eq!(roundtripped.total_issues, 0);
396        assert_eq!(roundtripped.unused_files, 0);
397    }
398
399    #[test]
400    fn deltas_empty_when_identical() {
401        let counts = CheckCounts {
402            total_issues: 10,
403            unused_files: 5,
404            unused_exports: 3,
405            unused_types: 2,
406            unused_dependencies: 0,
407            unused_dev_dependencies: 0,
408            unused_optional_dependencies: 0,
409            unused_enum_members: 0,
410            unused_class_members: 0,
411            unresolved_imports: 0,
412            unlisted_dependencies: 0,
413            duplicate_exports: 0,
414            circular_dependencies: 0,
415            re_export_cycles: 0,
416            type_only_dependencies: 0,
417            test_only_dependencies: 0,
418            boundary_violations: 0,
419            boundary_coverage_violations: 0,
420            boundary_call_violations: 0,
421            policy_violations: 0,
422        };
423        let deltas = counts.deltas(&counts);
424        assert!(deltas.is_empty());
425    }
426
427    #[test]
428    fn deltas_all_categories_changed() {
429        let baseline = CheckCounts {
430            total_issues: 0,
431            unused_files: 0,
432            unused_exports: 0,
433            unused_types: 0,
434            unused_dependencies: 0,
435            unused_dev_dependencies: 0,
436            unused_optional_dependencies: 0,
437            unused_enum_members: 0,
438            unused_class_members: 0,
439            unresolved_imports: 0,
440            unlisted_dependencies: 0,
441            duplicate_exports: 0,
442            circular_dependencies: 0,
443            re_export_cycles: 0,
444            type_only_dependencies: 0,
445            test_only_dependencies: 0,
446            boundary_violations: 0,
447            boundary_coverage_violations: 0,
448            boundary_call_violations: 0,
449            policy_violations: 0,
450        };
451        let current = CheckCounts {
452            total_issues: 14,
453            unused_files: 1,
454            unused_exports: 1,
455            unused_types: 1,
456            unused_dependencies: 1,
457            unused_dev_dependencies: 1,
458            unused_optional_dependencies: 1,
459            unused_enum_members: 1,
460            unused_class_members: 1,
461            unresolved_imports: 1,
462            unlisted_dependencies: 1,
463            duplicate_exports: 1,
464            circular_dependencies: 1,
465            re_export_cycles: 0,
466            type_only_dependencies: 1,
467            test_only_dependencies: 1,
468            boundary_violations: 1,
469            boundary_coverage_violations: 0,
470            boundary_call_violations: 0,
471            policy_violations: 0,
472        };
473        let deltas = baseline.deltas(&current);
474        assert_eq!(deltas.len(), 15);
475        for (_, d) in &deltas {
476            assert_eq!(*d, 1);
477        }
478    }
479
480    #[test]
481    fn deltas_mixed_increase_decrease() {
482        let baseline = CheckCounts {
483            total_issues: 10,
484            unused_files: 5,
485            unused_exports: 3,
486            unused_types: 2,
487            unused_dependencies: 0,
488            unused_dev_dependencies: 0,
489            unused_optional_dependencies: 0,
490            unused_enum_members: 0,
491            unused_class_members: 0,
492            unresolved_imports: 0,
493            unlisted_dependencies: 0,
494            duplicate_exports: 0,
495            circular_dependencies: 0,
496            re_export_cycles: 0,
497            type_only_dependencies: 0,
498            test_only_dependencies: 0,
499            boundary_violations: 0,
500            boundary_coverage_violations: 0,
501            boundary_call_violations: 0,
502            policy_violations: 0,
503        };
504        let current = CheckCounts {
505            unused_files: 3,
506            unused_exports: 5,
507            unused_types: 0,
508            unresolved_imports: 1,
509            ..baseline
510        };
511        let deltas = baseline.deltas(&current);
512        assert_eq!(deltas.len(), 4);
513        assert!(deltas.contains(&("unused_files", -2)));
514        assert!(deltas.contains(&("unused_exports", 2)));
515        assert!(deltas.contains(&("unused_types", -2)));
516        assert!(deltas.contains(&("unresolved_imports", 1)));
517    }
518
519    #[test]
520    fn dupes_counts_roundtrip() {
521        let dupes = DupesCounts {
522            clone_groups: 8,
523            duplication_percentage: 3.17,
524        };
525        let json = serde_json::to_string(&dupes).unwrap();
526        let loaded: DupesCounts = serde_json::from_str(&json).unwrap();
527        assert_eq!(loaded.clone_groups, 8);
528        assert!((loaded.duplication_percentage - 3.17).abs() < f64::EPSILON);
529    }
530
531    #[test]
532    fn dupes_counts_default_fields() {
533        let json = "{}";
534        let loaded: DupesCounts = serde_json::from_str(json).unwrap();
535        assert_eq!(loaded.clone_groups, 0);
536        assert!((loaded.duplication_percentage).abs() < f64::EPSILON);
537    }
538
539    #[test]
540    fn baseline_without_check_section() {
541        let baseline = RegressionBaseline {
542            schema_version: 1,
543            fallow_version: "2.4.0".into(),
544            timestamp: "2026-03-27T10:00:00Z".into(),
545            git_sha: None,
546            check: None,
547            dupes: Some(DupesCounts {
548                clone_groups: 3,
549                duplication_percentage: 1.0,
550            }),
551        };
552        let json = serde_json::to_string_pretty(&baseline).unwrap();
553        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
554        assert!(loaded.check.is_none());
555        assert!(loaded.dupes.is_some());
556    }
557
558    #[test]
559    fn baseline_without_dupes_section() {
560        let baseline = RegressionBaseline {
561            schema_version: 1,
562            fallow_version: "2.4.0".into(),
563            timestamp: "2026-03-27T10:00:00Z".into(),
564            git_sha: Some("deadbeef".into()),
565            check: Some(CheckCounts {
566                total_issues: 1,
567                unused_files: 1,
568                ..CheckCounts::from_config_baseline(&fallow_config::RegressionBaseline::default())
569            }),
570            dupes: None,
571        };
572        let json = serde_json::to_string_pretty(&baseline).unwrap();
573        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
574        assert!(loaded.check.is_some());
575        assert!(loaded.dupes.is_none());
576        assert_eq!(loaded.git_sha.as_deref(), Some("deadbeef"));
577    }
578
579    #[test]
580    fn baseline_without_git_sha() {
581        let baseline = RegressionBaseline {
582            schema_version: 1,
583            fallow_version: "2.4.0".into(),
584            timestamp: "2026-03-27T10:00:00Z".into(),
585            git_sha: None,
586            check: None,
587            dupes: None,
588        };
589        let json = serde_json::to_string_pretty(&baseline).unwrap();
590        assert!(!json.contains("git_sha"));
591        let loaded: RegressionBaseline = serde_json::from_str(&json).unwrap();
592        assert!(loaded.git_sha.is_none());
593    }
594
595    #[test]
596    fn baseline_json_with_unknown_check_fields_deserializes() {
597        let json = r#"{
598            "schema_version": 1,
599            "fallow_version": "3.0.0",
600            "timestamp": "2026-03-27T10:00:00Z",
601            "check": {
602                "total_issues": 10,
603                "unused_files": 2,
604                "some_future_field": 99
605            }
606        }"#;
607        let loaded: Result<RegressionBaseline, _> = serde_json::from_str(json);
608        assert!(loaded.is_ok());
609        let loaded = loaded.unwrap();
610        assert_eq!(loaded.check.as_ref().unwrap().total_issues, 10);
611    }
612}