Skip to main content

fallow_cli/regression/
counts.rs

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