Skip to main content

fallow_cli/regression/
counts.rs

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