1use fallow_types::extract::FileBranching;
12use rustc_hash::FxHashMap;
13use serde::Serialize;
14
15pub type BranchingSnapshot = FxHashMap<String, FileBranching>;
17
18pub const DEFAULT_BRANCHING_TOLERANCE: u32 = 2;
23
24const MAX_BY_FILE: usize = 5;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct BranchingMetric {
31 pub previous: u32,
33 pub current: u32,
35 pub delta: i64,
38}
39
40impl BranchingMetric {
41 fn new(previous: u32, current: u32) -> Self {
42 Self {
43 previous,
44 current,
45 delta: i64::from(current) - i64::from(previous),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[serde(rename_all = "kebab-case")]
54pub enum CognitiveAttribution {
55 NestingReset,
58 FewerBranchPoints,
63 Mixed,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct BranchingCognitive {
79 pub previous: u32,
81 pub current: u32,
83 pub delta: i64,
85 pub nesting_weight_delta: i64,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub attributed_to: Option<CognitiveAttribution>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96pub struct BranchingScope {
97 pub files_both: u32,
99 pub files_added: u32,
101 pub files_only_in_base: u32,
106 pub test_branch_points: u32,
110 pub test_functions: u32,
112 pub largest_file_share_of_branch_points: f64,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub struct SplitInPlace {
140 pub path: String,
142 pub branch_points_before: u32,
144 pub branch_points_after: u32,
148 pub functions_before: u32,
150 pub functions_after: u32,
152 pub peak_before: u16,
154 pub peak_after: u16,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct BranchingFileDelta {
163 pub path: String,
165 pub branch_points_delta: i64,
167 pub functions_delta: i64,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct BranchingReport {
175 pub split_in_place: Vec<SplitInPlace>,
181 pub tolerance: u32,
185 pub scope: BranchingScope,
187 pub branch_points: BranchingMetric,
192 pub functions: BranchingMetric,
194 pub peak_unit_cyclomatic: BranchingMetric,
198 pub branch_points_only_in_base: u32,
200 pub cognitive: BranchingCognitive,
202 pub by_file: Vec<BranchingFileDelta>,
205 pub by_file_omitted: u32,
207}
208
209#[derive(Default, Clone, Copy)]
211struct Totals {
212 branch_points: u32,
213 functions: u32,
214 peak: u16,
215 cognitive: u32,
216 nesting: u32,
217}
218
219impl Totals {
220 fn add(&mut self, file: FileBranching) {
221 self.branch_points += file.branch_points;
222 self.functions += file.functions;
223 self.peak = self.peak.max(file.peak_cyclomatic);
224 self.cognitive += file.cognitive;
225 self.nesting += file.cognitive_nesting_weight;
226 }
227}
228
229struct Partition {
235 surviving_base: Totals,
236 surviving_head: Totals,
237 deleted: Totals,
238 scope: BranchingScope,
239 deltas: Vec<BranchingFileDelta>,
240 split_in_place: Vec<SplitInPlace>,
241}
242
243fn partition(
244 base: &BranchingSnapshot,
245 head: &BranchingSnapshot,
246 tolerance: u32,
247 is_test_path: &dyn Fn(&str) -> bool,
248) -> Partition {
249 let mut out = Partition {
250 surviving_base: Totals::default(),
251 surviving_head: Totals::default(),
252 deleted: Totals::default(),
253 scope: BranchingScope {
254 files_both: 0,
255 files_added: 0,
256 files_only_in_base: 0,
257 test_branch_points: 0,
258 test_functions: 0,
259 largest_file_share_of_branch_points: 0.0,
260 },
261 deltas: Vec::new(),
262 split_in_place: Vec::new(),
263 };
264 let mut test_totals = Totals::default();
265
266 for (path, head_file) in head {
267 out.surviving_head.add(*head_file);
268 if is_test_path(path) {
269 test_totals.add(*head_file);
270 }
271 let Some(base_file) = base.get(path) else {
272 out.scope.files_added += 1;
273 out.deltas.push(BranchingFileDelta {
274 path: path.clone(),
275 branch_points_delta: i64::from(head_file.branch_points),
276 functions_delta: i64::from(head_file.functions),
277 });
278 continue;
279 };
280 out.scope.files_both += 1;
281 out.surviving_base.add(*base_file);
282 let branch_delta = i64::from(head_file.branch_points) - i64::from(base_file.branch_points);
283 let function_delta = i64::from(head_file.functions) - i64::from(base_file.functions);
284 let authored_function_delta = function_delta - i64::from(head_file.has_module_unit)
290 + i64::from(base_file.has_module_unit);
291 if branch_delta.unsigned_abs() <= u64::from(tolerance)
294 && authored_function_delta > 0
295 && head_file.peak_cyclomatic < base_file.peak_cyclomatic
296 && !is_test_path(path)
297 && !is_excluded_from_the_claim(path)
298 && !head_file.has_synthetic_units
299 && !base_file.has_synthetic_units
300 {
301 out.split_in_place.push(SplitInPlace {
302 path: path.clone(),
303 branch_points_before: base_file.branch_points,
304 branch_points_after: head_file.branch_points,
305 functions_before: base_file.functions,
306 functions_after: head_file.functions,
307 peak_before: base_file.peak_cyclomatic,
308 peak_after: head_file.peak_cyclomatic,
309 });
310 }
311 if branch_delta != 0 || function_delta != 0 {
312 out.deltas.push(BranchingFileDelta {
313 path: path.clone(),
314 branch_points_delta: branch_delta,
315 functions_delta: function_delta,
316 });
317 }
318 }
319
320 for (path, base_file) in base {
321 if !head.contains_key(path) {
322 out.scope.files_only_in_base += 1;
323 out.deleted.add(*base_file);
324 }
325 }
326
327 out.scope.test_branch_points = test_totals.branch_points;
328 out.scope.test_functions = test_totals.functions;
329 out
330}
331
332fn is_excluded_from_the_claim(path: &str) -> bool {
339 [
340 "/generated/",
341 "/vendor/",
342 "/dist/",
343 "/node_modules/",
344 "/__generated__/",
345 ]
346 .iter()
347 .any(|marker| path.contains(marker))
348 || path.starts_with("generated/")
349 || path.starts_with("vendor/")
350 || path.starts_with("dist/")
351}
352
353fn scope_largest(head: &BranchingSnapshot) -> u32 {
354 head.values()
355 .map(|file| file.branch_points)
356 .max()
357 .unwrap_or(0)
358}
359
360impl BranchingReport {
361 #[must_use]
368 pub fn compare(
369 base: &BranchingSnapshot,
370 head: &BranchingSnapshot,
371 tolerance: u32,
372 is_test_path: &dyn Fn(&str) -> bool,
373 ) -> Self {
374 let Partition {
375 surviving_base,
376 surviving_head,
377 deleted,
378 mut scope,
379 mut deltas,
380 mut split_in_place,
381 } = partition(base, head, tolerance, is_test_path);
382
383 scope.largest_file_share_of_branch_points = if surviving_head.branch_points == 0 {
384 0.0
385 } else {
386 f64::from(scope_largest(head)) / f64::from(surviving_head.branch_points)
387 };
388
389 let branch_points =
390 BranchingMetric::new(surviving_base.branch_points, surviving_head.branch_points);
391 let functions = BranchingMetric::new(surviving_base.functions, surviving_head.functions);
392 let peak_unit_cyclomatic = BranchingMetric::new(
393 u32::from(surviving_base.peak),
394 u32::from(surviving_head.peak),
395 );
396 let cognitive_delta =
397 i64::from(surviving_head.cognitive) - i64::from(surviving_base.cognitive);
398 let nesting_weight_delta =
399 i64::from(surviving_head.nesting) - i64::from(surviving_base.nesting);
400
401 split_in_place.sort_by(|a, b| {
402 (b.functions_after - b.functions_before)
403 .cmp(&(a.functions_after - a.functions_before))
404 .then_with(|| a.path.cmp(&b.path))
405 });
406
407 deltas.sort_by(|a, b| {
408 b.branch_points_delta
409 .abs()
410 .cmp(&a.branch_points_delta.abs())
411 .then_with(|| b.functions_delta.abs().cmp(&a.functions_delta.abs()))
412 .then_with(|| a.path.cmp(&b.path))
413 });
414 let by_file_omitted = u32::try_from(deltas.len().saturating_sub(MAX_BY_FILE)).unwrap_or(0);
415 deltas.truncate(MAX_BY_FILE);
416
417 Self {
418 split_in_place,
419 tolerance,
420 scope,
421 branch_points,
422 functions,
423 peak_unit_cyclomatic,
424 branch_points_only_in_base: deleted.branch_points,
425 cognitive: BranchingCognitive {
426 previous: surviving_base.cognitive,
427 current: surviving_head.cognitive,
428 delta: cognitive_delta,
429 nesting_weight_delta,
430 attributed_to: attribute_cognitive(
431 cognitive_delta,
432 branch_points.delta,
433 nesting_weight_delta,
434 tolerance,
435 ),
436 },
437 by_file: deltas,
438 by_file_omitted,
439 }
440 }
441
442 #[must_use]
448 pub fn is_reportable(&self) -> bool {
449 !self.split_in_place.is_empty()
450 }
451}
452
453fn attribute_cognitive(
466 cognitive_delta: i64,
467 branch_delta: i64,
468 nesting_weight_delta: i64,
469 tolerance: u32,
470) -> Option<CognitiveAttribution> {
471 if cognitive_delta >= 0 {
472 return None;
473 }
474 let branches_removed = branch_delta < -i64::from(tolerance);
475 let nesting_reset = nesting_weight_delta < 0;
476 Some(match (branches_removed, nesting_reset) {
477 (true, false) => CognitiveAttribution::FewerBranchPoints,
478 (false, true) => CognitiveAttribution::NestingReset,
479 (true, true) | (false, false) => CognitiveAttribution::Mixed,
483 })
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn file(branch_points: u32, functions: u32, peak: u16) -> FileBranching {
491 FileBranching {
492 branch_points,
493 functions,
494 peak_cyclomatic: peak,
495 cognitive: branch_points,
496 cognitive_nesting_weight: 0,
497 has_module_unit: false,
498 has_synthetic_units: false,
499 }
500 }
501
502 fn snapshot(entries: &[(&str, FileBranching)]) -> BranchingSnapshot {
503 entries
504 .iter()
505 .map(|(path, totals)| ((*path).to_string(), *totals))
506 .collect()
507 }
508
509 fn compare(base: &BranchingSnapshot, head: &BranchingSnapshot) -> BranchingReport {
510 BranchingReport::compare(base, head, DEFAULT_BRANCHING_TOLERANCE, &|path| {
511 path.contains(".test.")
512 })
513 }
514
515 #[test]
516 fn a_file_that_splits_in_place_is_named() {
517 let base = snapshot(&[("src/a.ts", file(39, 1, 40))]);
518 let head = snapshot(&[("src/a.ts", file(39, 8, 6))]);
519
520 let report = compare(&base, &head);
521
522 assert_eq!(report.split_in_place.len(), 1);
523 let split = &report.split_in_place[0];
524 assert_eq!(split.path, "src/a.ts");
525 assert_eq!(
526 (split.branch_points_before, split.branch_points_after),
527 (39, 39)
528 );
529 assert_eq!((split.functions_before, split.functions_after), (1, 8));
530 assert_eq!((split.peak_before, split.peak_after), (40, 6));
531 assert!(report.is_reportable());
532 }
533
534 #[test]
535 fn the_claim_is_local_so_unrelated_work_cannot_change_it() {
536 let base = snapshot(&[
541 ("src/a.ts", file(39, 1, 40)),
542 ("src/b.ts", file(12, 3, 5)),
543 ("src/d.ts", file(0, 1, 1)),
544 ("src/gone.ts", file(300, 9, 40)),
545 ]);
546 let head = snapshot(&[
547 ("src/a.ts", file(39, 8, 6)),
548 ("src/b.ts", file(4, 3, 5)),
549 ("src/d.ts", file(208, 1, 90)),
550 ("src/added.ts", file(0, 1, 1)),
551 ]);
552
553 let report = compare(&base, &head);
554
555 assert_eq!(
556 report.split_in_place.len(),
557 1,
558 "only src/a.ts split; nothing else in the changeset makes that more or less true"
559 );
560 assert_eq!(report.split_in_place[0].path, "src/a.ts");
561 }
562
563 #[test]
564 fn a_split_that_lands_in_a_test_file_is_not_an_in_place_split() {
565 let base = snapshot(&[("src/pricing.ts", file(48, 4, 20))]);
567 let head = snapshot(&[
568 ("src/pricing.ts", file(0, 1, 1)),
569 ("src/pricing.test.ts", file(48, 16, 4)),
570 ]);
571
572 let report = compare(&base, &head);
573
574 assert!(report.split_in_place.is_empty());
575 assert!(!report.is_reportable());
576 assert_eq!(
577 report.scope.test_branch_points, 48,
578 "still reported as scope"
579 );
580 }
581
582 #[test]
583 fn a_file_whose_branching_fell_reports_both_numbers() {
584 let base = snapshot(&[("src/a.ts", file(2, 1, 3))]);
587 let head = snapshot(&[("src/a.ts", file(0, 2, 1))]);
588
589 let report = compare(&base, &head);
590
591 let split = &report.split_in_place[0];
592 assert_eq!(
593 (split.branch_points_before, split.branch_points_after),
594 (2, 0)
595 );
596 }
597
598 #[test]
599 fn a_test_file_never_carries_the_claim() {
600 let base = snapshot(&[("src/a.test.ts", file(30, 1, 31))]);
603 let head = snapshot(&[("src/a.test.ts", file(30, 8, 6))]);
604
605 let report = compare(&base, &head);
606
607 assert!(report.split_in_place.is_empty());
608 assert_eq!(report.scope.test_branch_points, 30);
609 }
610
611 #[test]
612 fn hoisting_branching_to_module_scope_is_not_an_in_place_split() {
613 let base = snapshot(&[("src/a.ts", file(5, 1, 6))]);
618 let head = snapshot(&[(
619 "src/a.ts",
620 FileBranching {
621 branch_points: 5,
622 functions: 2,
623 peak_cyclomatic: 3,
624 cognitive: 5,
625 cognitive_nesting_weight: 0,
626 has_module_unit: true,
627 has_synthetic_units: false,
628 },
629 )]);
630
631 let report = compare(&base, &head);
632
633 assert_eq!(
634 report.branch_points.delta, 0,
635 "the branching survives the hoist, which is why it is counted"
636 );
637 assert!(
638 report.split_in_place.is_empty(),
639 "no authored function was added, so this is not a split"
640 );
641 }
642
643 #[test]
644 fn a_split_in_a_file_that_also_has_module_branching_still_counts() {
645 let base = snapshot(&[(
647 "src/a.ts",
648 FileBranching {
649 branch_points: 40,
650 functions: 2,
651 peak_cyclomatic: 39,
652 cognitive: 40,
653 cognitive_nesting_weight: 0,
654 has_module_unit: true,
655 has_synthetic_units: false,
656 },
657 )]);
658 let head = snapshot(&[(
659 "src/a.ts",
660 FileBranching {
661 branch_points: 40,
662 functions: 9,
663 peak_cyclomatic: 6,
664 cognitive: 40,
665 cognitive_nesting_weight: 0,
666 has_module_unit: true,
667 has_synthetic_units: false,
668 },
669 )]);
670
671 let report = compare(&base, &head);
672
673 assert_eq!(report.split_in_place.len(), 1);
674 }
675
676 #[test]
677 fn a_file_with_synthetic_template_units_never_carries_the_claim() {
678 let with_template = |branch_points: u32, functions: u32, peak: u16| FileBranching {
681 branch_points,
682 functions,
683 peak_cyclomatic: peak,
684 cognitive: branch_points,
685 cognitive_nesting_weight: 0,
686 has_module_unit: false,
687 has_synthetic_units: true,
688 };
689 let base = snapshot(&[("src/App.vue", with_template(6, 1, 7))]);
690 let head = snapshot(&[("src/App.vue", with_template(6, 4, 3))]);
691
692 let report = compare(&base, &head);
693
694 assert!(report.split_in_place.is_empty());
695 }
696
697 #[test]
698 fn generated_and_vendored_paths_never_carry_the_claim() {
699 for path in [
702 "src/__generated__/schema.ts",
703 "vendor/bundle.js",
704 "dist/main.js",
705 "packages/app/generated/api.ts",
706 ] {
707 let base = snapshot(&[(path, file(30, 1, 31))]);
708 let head = snapshot(&[(path, file(30, 8, 6))]);
709 assert!(
710 compare(&base, &head).split_in_place.is_empty(),
711 "{path} should not carry the claim"
712 );
713 }
714 }
715
716 #[test]
717 fn a_split_with_a_little_glue_branching_still_counts() {
718 let base = snapshot(&[("src/a.ts", file(30, 1, 31))]);
719 let head = snapshot(&[("src/a.ts", file(32, 6, 8))]);
720
721 let report = compare(&base, &head);
722
723 assert_eq!(report.split_in_place.len(), 1);
724 }
725
726 #[test]
727 fn branching_arriving_in_a_file_is_not_a_split() {
728 let base = snapshot(&[("src/a.ts", file(10, 1, 11))]);
729 let head = snapshot(&[("src/a.ts", file(40, 5, 12))]);
730
731 let report = compare(&base, &head);
732
733 assert!(report.split_in_place.is_empty());
734 }
735
736 #[test]
737 fn a_file_whose_peak_held_is_not_a_split() {
738 let base = snapshot(&[("src/a.ts", file(30, 2, 20))]);
739 let head = snapshot(&[("src/a.ts", file(30, 6, 20))]);
740
741 let report = compare(&base, &head);
742
743 assert!(
744 report.split_in_place.is_empty(),
745 "functions rose and branching held, but the worst function is untouched"
746 );
747 }
748
749 #[test]
750 fn splits_are_ordered_by_how_far_the_file_was_partitioned() {
751 let base = snapshot(&[("src/a.ts", file(9, 1, 10)), ("src/b.ts", file(20, 1, 21))]);
752 let head = snapshot(&[("src/a.ts", file(9, 3, 4)), ("src/b.ts", file(20, 9, 5))]);
753
754 let report = compare(&base, &head);
755
756 assert_eq!(
757 report
758 .split_in_place
759 .iter()
760 .map(|s| s.path.as_str())
761 .collect::<Vec<_>>(),
762 vec!["src/b.ts", "src/a.ts"]
763 );
764 }
765
766 #[test]
767 fn the_set_totals_still_describe_the_changeset() {
768 let base = snapshot(&[
769 ("src/a.ts", file(30, 5, 8)),
770 ("src/gone.ts", file(300, 9, 40)),
771 ]);
772 let head = snapshot(&[("src/a.ts", file(30, 5, 8)), ("src/new.ts", file(9, 6, 4))]);
773
774 let report = compare(&base, &head);
775
776 assert_eq!(report.branch_points.previous, 30);
777 assert_eq!(report.branch_points.current, 39);
778 assert_eq!(report.functions.delta, 6);
779 assert_eq!(report.scope.files_added, 1);
780 assert_eq!(report.scope.files_only_in_base, 1);
781 assert_eq!(report.branch_points_only_in_base, 300);
782 assert!(
783 !report.is_reportable(),
784 "totals alone are context, not news"
785 );
786 }
787
788 #[test]
789 fn an_empty_accounting_set_reports_nothing() {
790 let report = compare(&snapshot(&[]), &snapshot(&[]));
791
792 assert!(report.split_in_place.is_empty());
793 assert_eq!(report.branch_points.current, 0);
794 assert!(!report.is_reportable());
795 }
796
797 #[test]
798 fn a_cognitive_win_with_branching_held_is_a_nesting_reset() {
799 let base = snapshot(&[(
800 "src/a.ts",
801 FileBranching {
802 branch_points: 10,
803 functions: 1,
804 peak_cyclomatic: 11,
805 cognitive: 40,
806 cognitive_nesting_weight: 30,
807 has_module_unit: false,
808 has_synthetic_units: false,
809 },
810 )]);
811 let head = snapshot(&[(
812 "src/a.ts",
813 FileBranching {
814 branch_points: 10,
815 functions: 6,
816 peak_cyclomatic: 4,
817 cognitive: 12,
818 cognitive_nesting_weight: 2,
819 has_module_unit: false,
820 has_synthetic_units: false,
821 },
822 )]);
823
824 let report = compare(&base, &head);
825
826 assert_eq!(report.cognitive.delta, -28);
827 assert_eq!(
828 report.cognitive.attributed_to,
829 Some(CognitiveAttribution::NestingReset)
830 );
831 }
832
833 #[test]
834 fn a_cognitive_rise_is_attributed_to_nothing() {
835 let base = snapshot(&[("src/a.ts", file(7, 1, 8))]);
836 let head = snapshot(&[("src/a.ts", file(11, 5, 5))]);
837
838 let report = compare(&base, &head);
839
840 assert!(report.cognitive.delta > 0);
841 assert_eq!(report.cognitive.attributed_to, None);
842 }
843
844 #[test]
845 fn test_paths_are_reported_separately() {
846 let base = snapshot(&[("src/a.ts", file(10, 2, 6))]);
847 let head = snapshot(&[
848 ("src/a.ts", file(10, 2, 6)),
849 ("src/a.test.ts", file(40, 30, 3)),
850 ]);
851
852 let report = compare(&base, &head);
853
854 assert_eq!(report.scope.test_branch_points, 40);
855 assert_eq!(report.scope.test_functions, 30);
856 }
857
858 #[test]
859 fn one_dominant_file_is_visible_in_the_share() {
860 let base = snapshot(&[("src/a.ts", file(1, 1, 2))]);
861 let head = snapshot(&[
862 ("src/a.ts", file(1, 1, 2)),
863 ("src/vendor/bundle.js", file(99, 5, 40)),
864 ]);
865
866 let report = compare(&base, &head);
867
868 assert!(
869 (report.scope.largest_file_share_of_branch_points - 0.99).abs() < 1e-9,
870 "{}",
871 report.scope.largest_file_share_of_branch_points
872 );
873 }
874
875 #[test]
876 fn the_file_list_is_capped_and_the_remainder_counted() {
877 let base = snapshot(&[]);
878 let head = snapshot(&[
879 ("src/a.ts", file(9, 1, 3)),
880 ("src/b.ts", file(8, 1, 3)),
881 ("src/c.ts", file(7, 1, 3)),
882 ("src/d.ts", file(6, 1, 3)),
883 ("src/e.ts", file(5, 1, 3)),
884 ("src/f.ts", file(4, 1, 3)),
885 ("src/g.ts", file(3, 1, 3)),
886 ]);
887
888 let report = compare(&base, &head);
889
890 assert_eq!(report.by_file.len(), 5);
891 assert_eq!(report.by_file_omitted, 2);
892 assert_eq!(report.by_file[0].path, "src/a.ts");
893 }
894}