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