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