1use std::path::Path;
36
37use fallow_output::{
38 AuditFindingAttribution, AuditGateExemption, CoverageSource, DEFAULT_COGNITIVE_CRITICAL,
39 DEFAULT_COGNITIVE_HIGH, DEFAULT_CYCLOMATIC_CRITICAL, DEFAULT_CYCLOMATIC_HIGH,
40 ExceededThreshold, FindingSeverity, WorsenedMetric, WorsenedMetricKind,
41 compute_finding_severity,
42};
43use fallow_types::output_dead_code::EffectiveSeverity;
44use rustc_hash::{FxHashMap, FxHashSet};
45
46use crate::audit_keys::{health_finding_key, remap_key_for_renames};
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ComplexityMetrics {
51 pub line: u32,
53 pub cyclomatic: u16,
55 pub cognitive: u16,
57 pub crap: Option<f64>,
59 pub line_count: u32,
61 pub exceeded: ExceededThreshold,
63 pub severity: FindingSeverity,
65 pub crap_estimated: bool,
67}
68
69impl ComplexityMetrics {
70 #[must_use]
72 pub const fn of(finding: &fallow_output::ComplexityViolation) -> Self {
73 Self {
74 line: finding.line,
75 cyclomatic: finding.cyclomatic,
76 cognitive: finding.cognitive,
77 crap: finding.crap,
78 line_count: finding.line_count,
79 exceeded: finding.exceeded,
80 severity: finding.severity,
81 crap_estimated: crap_is_estimated(finding),
82 }
83 }
84
85 fn same_values(&self, other: &Self) -> bool {
88 self.cyclomatic == other.cyclomatic
89 && self.cognitive == other.cognitive
90 && self.crap.map(f64::to_bits) == other.crap.map(f64::to_bits)
91 }
92
93 fn same_values_and_lines(&self, other: &Self) -> bool {
96 self.same_values(other) && self.line_count == other.line_count
97 }
98
99 const fn same_structure(&self, other: &Self) -> bool {
103 self.cyclomatic == other.cyclomatic && self.cognitive == other.cognitive
104 }
105
106 const fn same_structure_and_lines(&self, other: &Self) -> bool {
109 self.same_structure(other) && self.line_count == other.line_count
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Default)]
115pub struct ComplexityClass {
116 pub introduced: bool,
119 pub worsened_metrics: Vec<WorsenedMetric>,
122 pub exceeded_metric_grew: bool,
126}
127
128impl ComplexityClass {
129 #[must_use]
132 pub fn is_worsened(&self) -> bool {
133 !self.introduced && !self.worsened_metrics.is_empty()
134 }
135
136 #[must_use]
138 pub fn attribution(&self) -> AuditFindingAttribution {
139 if self.introduced {
140 AuditFindingAttribution::Introduced
141 } else if self.worsened_metrics.is_empty() {
142 AuditFindingAttribution::Inherited
143 } else {
144 AuditFindingAttribution::Worsened
145 }
146 }
147
148 pub fn clear_worsened_for_off(&mut self) {
153 if !self.introduced && !self.exceeded_metric_grew {
154 self.worsened_metrics.clear();
155 }
156 }
157
158 fn of_match(
159 head: &fallow_output::ComplexityViolation,
160 base: Option<&ComplexityMetrics>,
161 ) -> Self {
162 base.map_or(
163 Self {
164 introduced: true,
165 worsened_metrics: Vec::new(),
166 exceeded_metric_grew: false,
167 },
168 |base| {
169 let exceeded_metric_grew = exceeded_metric_increased(head, base);
170 Self {
171 introduced: newly_exceeded_metric_grew(head, base)
172 || (exceeded_metric_grew && severity_tier_rose(head, base)),
173 worsened_metrics: grown_metrics(head, base),
174 exceeded_metric_grew,
175 }
176 },
177 )
178 }
179}
180
181fn grown_metrics(
187 head: &fallow_output::ComplexityViolation,
188 base: &ComplexityMetrics,
189) -> Vec<WorsenedMetric> {
190 let mut grown = Vec::new();
191 let mut push = |metric, base: f64, head: f64| {
192 if head > base {
193 grown.push(WorsenedMetric { metric, base, head });
194 }
195 };
196 push(
197 WorsenedMetricKind::Cyclomatic,
198 f64::from(base.cyclomatic),
199 f64::from(head.cyclomatic),
200 );
201 push(
202 WorsenedMetricKind::Cognitive,
203 f64::from(base.cognitive),
204 f64::from(head.cognitive),
205 );
206 if let (Some(base_crap), Some(head_crap)) = (base.crap, head.crap) {
207 push(WorsenedMetricKind::Crap, base_crap, head_crap);
208 }
209 if line_count_grew(base.line_count, head.line_count) {
210 grown.push(WorsenedMetric {
211 metric: WorsenedMetricKind::LineCount,
212 base: f64::from(base.line_count),
213 head: f64::from(head.line_count),
214 });
215 }
216 grown
217}
218
219const LINE_COUNT_MIN_GROWTH_LINES: u32 = 5;
221
222fn line_count_grew(base: u32, head: u32) -> bool {
230 head.saturating_sub(base) >= LINE_COUNT_MIN_GROWTH_LINES
231}
232
233pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
236
237#[must_use]
239pub fn complexity_baseline(
240 report: &fallow_output::HealthReport,
241 root: &Path,
242) -> ComplexityBaseline {
243 let mut baseline = ComplexityBaseline::default();
244 for finding in &report.findings {
245 baseline
246 .entry(health_finding_key(finding, root))
247 .or_default()
248 .push(ComplexityMetrics::of(finding));
249 }
250 sort_by_line(&mut baseline);
251 baseline
252}
253
254#[must_use]
256pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
257 baseline.keys().cloned().collect()
258}
259
260#[must_use]
266#[expect(
267 clippy::implicit_hasher,
268 reason = "fallow standardizes on FxHashMap across audit attribution keys"
269)]
270pub fn remap_complexity_baseline_for_renames(
271 baseline: ComplexityBaseline,
272 renames: &FxHashMap<String, String>,
273) -> ComplexityBaseline {
274 let mut remapped = ComplexityBaseline::default();
275 for (key, findings) in baseline {
276 remapped
277 .entry(remap_key_for_renames(&key, renames))
278 .or_default()
279 .extend(findings);
280 }
281 sort_by_line(&mut remapped);
282 remapped
283}
284
285fn sort_by_line(baseline: &mut ComplexityBaseline) {
286 for findings in baseline.values_mut() {
287 findings.sort_by_key(|metrics| metrics.line);
288 }
289}
290
291fn exceeded_metric_increased(
296 head: &fallow_output::ComplexityViolation,
297 base: &ComplexityMetrics,
298) -> bool {
299 let exceeded = head.exceeded;
300 (exceeded.includes_cyclomatic() && head.cyclomatic > base.cyclomatic)
301 || (exceeded.includes_cognitive() && head.cognitive > base.cognitive)
302 || (exceeded.includes_crap() && measured_crap_grew(head, base))
303}
304
305fn newly_exceeded_metric_grew(
309 head: &fallow_output::ComplexityViolation,
310 base: &ComplexityMetrics,
311) -> bool {
312 let (now, before) = (head.exceeded, base.exceeded);
313 (now.includes_cyclomatic()
314 && !before.includes_cyclomatic()
315 && head.cyclomatic > base.cyclomatic)
316 || (now.includes_cognitive()
317 && !before.includes_cognitive()
318 && head.cognitive > base.cognitive)
319 || (now.includes_crap() && !before.includes_crap() && measured_crap_grew(head, base))
320}
321
322fn measured_crap_grew(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
326 crap_is_comparable(head, base)
327 && matches!((head.crap, base.crap), (Some(head), Some(base)) if head > base)
328}
329
330fn crap_is_comparable(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
331 head.crap.is_some() && base.crap.is_some() && !crap_is_estimated(head) && !base.crap_estimated
332}
333
334fn severity_tier_rose(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
340 if crap_is_comparable(head, base) {
341 return head.severity > base.severity;
342 }
343 complexity_tier(head.cognitive, head.cyclomatic)
344 > complexity_tier(base.cognitive, base.cyclomatic)
345}
346
347fn complexity_tier(cognitive: u16, cyclomatic: u16) -> FindingSeverity {
348 compute_finding_severity(
349 cognitive,
350 cyclomatic,
351 None,
352 DEFAULT_COGNITIVE_HIGH,
353 DEFAULT_COGNITIVE_CRITICAL,
354 DEFAULT_CYCLOMATIC_HIGH,
355 DEFAULT_CYCLOMATIC_CRITICAL,
356 )
357}
358
359#[must_use]
362pub const fn crap_is_estimated(finding: &fallow_output::ComplexityViolation) -> bool {
363 matches!(
364 finding.coverage_source,
365 Some(CoverageSource::Estimated | CoverageSource::EstimatedComponentInherited)
366 )
367}
368
369#[must_use]
372pub const fn exceeds_only_estimated_crap(finding: &fallow_output::ComplexityViolation) -> bool {
373 matches!(finding.exceeded, ExceededThreshold::Crap) && crap_is_estimated(finding)
374}
375
376pub fn demote_estimated_crap_gates(findings: &mut [fallow_output::HealthFinding]) {
384 for finding in findings {
385 if exceeds_only_estimated_crap(finding)
386 && finding.effective_severity == Some(EffectiveSeverity::Error)
387 {
388 finding.violation.effective_severity = Some(EffectiveSeverity::Warn);
389 }
390 }
391}
392
393pub fn exempt_test_file_gates(findings: &mut [fallow_output::HealthFinding], root: &Path) {
401 for finding in findings {
402 let relative = finding.path.strip_prefix(root).unwrap_or(&finding.path);
403 if fallow_engine::test_paths::is_test_path(root, relative) {
404 finding.violation.effective_severity = Some(EffectiveSeverity::Warn);
405 finding.gate_exemption = Some(AuditGateExemption::TestFile);
406 }
407 }
408}
409
410#[must_use]
413pub fn classify_complexity_findings(
414 findings: &[fallow_output::HealthFinding],
415 root: &Path,
416 base: &ComplexityBaseline,
417) -> Vec<bool> {
418 classify_complexity_attribution(findings, root, base)
419 .into_iter()
420 .map(|class| class.introduced)
421 .collect()
422}
423
424#[must_use]
456pub fn classify_complexity_attribution(
457 findings: &[fallow_output::HealthFinding],
458 root: &Path,
459 base: &ComplexityBaseline,
460) -> Vec<ComplexityClass> {
461 let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
462 for (index, finding) in findings.iter().enumerate() {
463 groups
464 .entry(health_finding_key(finding, root))
465 .or_default()
466 .push(index);
467 }
468 let mut matches: Vec<Option<&ComplexityMetrics>> = vec![None; findings.len()];
469 for (key, mut heads) in groups {
470 let Some(bases) = base.get(&key) else {
471 continue;
472 };
473 heads.sort_by_key(|&index| (findings[index].line, findings[index].col));
474 let mut unmatched_bases: Vec<Option<&ComplexityMetrics>> = bases.iter().map(Some).collect();
475 let rules: [fn(&ComplexityMetrics, &ComplexityMetrics) -> bool; 4] = [
476 ComplexityMetrics::same_values_and_lines,
477 ComplexityMetrics::same_values,
478 ComplexityMetrics::same_structure_and_lines,
479 ComplexityMetrics::same_structure,
480 ];
481 for rule in rules {
482 for &index in &heads {
483 if matches[index].is_some() {
484 continue;
485 }
486 let head = ComplexityMetrics::of(&findings[index]);
487 if let Some(slot) = unmatched_bases
488 .iter_mut()
489 .find(|slot| slot.is_some_and(|base| rule(base, &head)))
490 {
491 matches[index] = slot.take();
492 }
493 }
494 }
495 let mut remaining_bases = unmatched_bases.into_iter().flatten();
496 for &index in &heads {
497 if matches[index].is_none() {
498 matches[index] = remaining_bases.next();
499 }
500 }
501 }
502 findings
503 .iter()
504 .zip(matches)
505 .map(|(finding, base)| ComplexityClass::of_match(finding, base))
506 .collect()
507}
508
509#[must_use]
513pub fn classify_complexity_findings_against_all<'a>(
514 findings: &[fallow_output::HealthFinding],
515 root: &Path,
516 bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
517) -> Vec<bool> {
518 classify_complexity_attribution_against_all(findings, root, bases)
519 .into_iter()
520 .map(|class| class.introduced)
521 .collect()
522}
523
524#[must_use]
533pub fn classify_complexity_attribution_against_all<'a>(
534 findings: &[fallow_output::HealthFinding],
535 root: &Path,
536 bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
537) -> Vec<ComplexityClass> {
538 let per_base: Vec<Vec<ComplexityClass>> = bases
539 .into_iter()
540 .map(|base| classify_complexity_attribution(findings, root, base))
541 .collect();
542 (0..findings.len())
543 .map(|index| {
544 let classes = per_base.iter().map(|classes| &classes[index]);
545 let inherited = classes
546 .clone()
547 .filter(|class| !class.introduced)
548 .min_by_key(|class| class.worsened_metrics.len());
549 inherited.cloned().unwrap_or_else(|| ComplexityClass {
550 introduced: true,
551 exceeded_metric_grew: false,
552 worsened_metrics: classes
553 .map(|class| &class.worsened_metrics)
554 .find(|metrics| !metrics.is_empty())
555 .cloned()
556 .unwrap_or_default(),
557 })
558 })
559 .collect()
560}
561
562#[must_use]
568pub fn complexity_ledger_keys(
569 findings: &[fallow_output::HealthFinding],
570 root: &Path,
571) -> Vec<String> {
572 let mut seen: FxHashMap<String, usize> = FxHashMap::default();
573 findings
574 .iter()
575 .map(|finding| {
576 let key = health_finding_key(finding, root);
577 let occurrence = seen.entry(key.clone()).or_default();
578 *occurrence += 1;
579 if *occurrence == 1 {
580 key
581 } else {
582 format!("{key}#{occurrence}")
583 }
584 })
585 .collect()
586}
587
588#[cfg(test)]
589mod tests {
590 use std::path::PathBuf;
591
592 use fallow_output::{
593 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
594 };
595
596 use super::*;
597
598 const MAX: u16 = 15;
599
600 fn root() -> PathBuf {
601 PathBuf::from("/project")
602 }
603
604 fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
605 HealthFinding::from(ComplexityViolation {
606 path: root().join("src/index.js"),
607 name: name.to_string(),
608 line,
609 col: 0,
610 cyclomatic,
611 standard_cyclomatic: None,
612 cognitive,
613 line_count: 30,
614 param_count: 1,
615 react_hook_count: 0,
616 react_jsx_max_depth: 0,
617 react_prop_count: 0,
618 react_hook_profile: None,
619 exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
620 effective_severity: None,
621 severity: FindingSeverity::High,
622 crap: None,
623 mutation: None,
624 coverage_pct: None,
625 coverage_tier: None,
626 coverage_source: None,
627 inherited_from: None,
628 component_rollup: None,
629 contributions: Vec::new(),
630 effective_thresholds: None,
631 threshold_source: None,
632 })
633 }
634
635 fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
636 complexity_baseline(
637 &HealthReport {
638 findings,
639 ..HealthReport::default()
640 },
641 &root(),
642 )
643 }
644
645 fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
646 classify_complexity_findings(head, &root(), &baseline(base))
647 }
648
649 #[test]
650 fn identity_key_excludes_category_and_line() {
651 let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
652 assert_eq!(key, "complexity:src/index.js:hotspot");
653 }
654
655 #[test]
656 fn issue_cases() {
657 assert_eq!(
659 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
660 [false]
661 );
662 assert_eq!(
664 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
665 [false]
666 );
667 assert_eq!(
669 classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
670 [false]
671 );
672 assert_eq!(
674 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
675 [false]
676 );
677 assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
679 }
680
681 #[test]
682 fn only_exceeded_metrics_can_introduce() {
683 assert_eq!(
686 classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
687 [false]
688 );
689 assert_eq!(
691 classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
692 [true]
693 );
694 }
695
696 #[test]
697 fn a_finding_that_any_base_has_is_inherited() {
698 let primary = baseline(vec![]);
699 let merged = baseline(vec![finding("f", 1, 17, 16)]);
700 let head = [finding("f", 1, 17, 16), finding("g", 9, 17, 16)];
701 assert_eq!(
702 classify_complexity_findings_against_all(&head, &root(), [&primary, &merged]),
703 [false, true]
704 );
705 assert_eq!(
708 classify_complexity_findings_against_all(
709 &[finding("f", 1, 31, 17)],
710 &root(),
711 [&baseline(vec![finding("f", 1, 17, 16)]), &merged]
712 ),
713 [true]
714 );
715 }
716
717 #[test]
718 fn line_shift_keeps_the_match() {
719 assert_eq!(
720 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
721 [false]
722 );
723 }
724
725 fn crap_finding(crap: Option<f64>, source: Option<CoverageSource>) -> HealthFinding {
726 let mut finding = finding("f", 1, 16, 5);
727 finding.violation.exceeded = ExceededThreshold::Crap;
728 finding.violation.crap = crap;
729 finding.violation.coverage_source = source;
730 finding.violation.effective_severity = Some(EffectiveSeverity::Error);
731 finding
732 }
733
734 #[test]
735 fn crap_without_base_value_is_not_an_increase() {
736 let head = crap_finding(Some(50.0), Some(CoverageSource::Istanbul));
739 assert_eq!(
740 classify(vec![crap_finding(None, None)], std::slice::from_ref(&head)),
741 [false]
742 );
743 assert_eq!(
745 classify(
746 vec![crap_finding(Some(40.0), Some(CoverageSource::Istanbul))],
747 std::slice::from_ref(&head)
748 ),
749 [false]
750 );
751 assert_eq!(
752 classify(
753 vec![crap_finding(Some(60.0), Some(CoverageSource::Istanbul))],
754 &[head]
755 ),
756 [false]
757 );
758 }
759
760 #[test]
761 fn estimated_crap_growth_is_worsened_not_introduced() {
762 let estimated = Some(CoverageSource::Estimated);
763 let classes = attribution(
764 vec![crap_finding(Some(40.0), estimated)],
765 &[crap_finding(Some(50.0), estimated)],
766 );
767 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
768 assert_eq!(
769 classes[0].worsened_metrics,
770 [metric(WorsenedMetricKind::Crap, 40.0, 50.0)]
771 );
772 let mut base = crap_finding(Some(40.0), estimated);
774 base.violation.exceeded = ExceededThreshold::CognitiveCrap;
775 base.violation.cognitive = 20;
776 let mut head = base.clone();
777 head.violation.cognitive = 21;
778 head.violation.crap = Some(50.0);
779 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [false]);
780 head.violation.cognitive = 25;
782 assert_eq!(classify(vec![base], &[head]), [true]);
783 }
784
785 #[test]
786 fn test_file_findings_get_warn_gate_severity_and_the_exemption() {
787 let project = root().join("tests").join("project");
790 let at = |relative: &str| {
791 let mut finding = finding("f", 1, 16, 5);
792 finding.violation.path = project.join(relative);
793 finding.violation.effective_severity = Some(EffectiveSeverity::Error);
794 finding
795 };
796 let mut findings = [
797 at("src/__tests__/app.js"),
798 at("src/app.spec.ts"),
799 at("src/app.test.tsx"),
800 at("src/__mocks__/api.js"),
801 at("src/app.js"),
802 at("src/latest.js"),
803 ];
804 exempt_test_file_gates(&mut findings, &project);
805 let marks: Vec<_> = findings
806 .iter()
807 .map(|finding| (finding.effective_severity, finding.gate_exemption))
808 .collect();
809 let exempt = (
810 Some(EffectiveSeverity::Warn),
811 Some(AuditGateExemption::TestFile),
812 );
813 let gated = (Some(EffectiveSeverity::Error), None);
814 assert_eq!(marks, [exempt, exempt, exempt, exempt, gated, gated]);
815 }
816
817 #[test]
818 fn estimated_crap_only_findings_get_warn_gate_severity() {
819 let mut both = crap_finding(Some(50.0), Some(CoverageSource::Estimated));
820 both.violation.exceeded = ExceededThreshold::CognitiveCrap;
821 let mut warn_rule = crap_finding(Some(50.0), Some(CoverageSource::Estimated));
822 warn_rule.violation.effective_severity = Some(EffectiveSeverity::Warn);
823 let mut findings = [
824 crap_finding(Some(50.0), Some(CoverageSource::Estimated)),
825 crap_finding(
826 Some(50.0),
827 Some(CoverageSource::EstimatedComponentInherited),
828 ),
829 crap_finding(Some(50.0), Some(CoverageSource::Istanbul)),
830 both,
831 warn_rule,
832 ];
833 demote_estimated_crap_gates(&mut findings);
834 let severities: Vec<_> = findings
835 .iter()
836 .map(|finding| finding.effective_severity)
837 .collect();
838 assert_eq!(
839 severities,
840 [
841 Some(EffectiveSeverity::Warn),
842 Some(EffectiveSeverity::Warn),
843 Some(EffectiveSeverity::Error),
844 Some(EffectiveSeverity::Error),
845 Some(EffectiveSeverity::Warn),
846 ]
847 );
848 }
849
850 #[test]
851 fn same_named_findings_match_unchanged_values_first() {
852 let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
853 assert_eq!(
855 classify(
856 base.clone(),
857 &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
858 ),
859 [false, false]
860 );
861 assert_eq!(
863 classify(base.clone(), &[finding("run", 1, 21, 20)]),
864 [false]
865 );
866 assert_eq!(
868 classify(
869 base.clone(),
870 &[
871 finding("run", 1, 30, 30),
872 finding("run", 20, 17, 16),
873 finding("run", 50, 21, 20)
874 ]
875 ),
876 [true, false, false]
877 );
878 assert_eq!(
880 classify(
881 base.clone(),
882 &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
883 ),
884 [false, false]
885 );
886 assert_eq!(
888 classify(
889 base,
890 &[finding("run", 1, 30, 17), finding("run", 30, 21, 20)]
891 ),
892 [true, false]
893 );
894 }
895
896 fn arrow(
898 line: u32,
899 cyclomatic: u16,
900 cognitive: u16,
901 crap: Option<f64>,
902 source: CoverageSource,
903 ) -> HealthFinding {
904 let mut finding = finding("<arrow>", line, MAX + 1, MAX + 1);
905 finding.violation.cyclomatic = cyclomatic;
906 finding.violation.cognitive = cognitive;
907 finding.violation.crap = crap;
908 finding.violation.coverage_source = crap.map(|_| source);
909 finding.violation.exceeded = ExceededThreshold::from_bools(
910 cyclomatic > MAX,
911 cognitive > MAX,
912 crap.is_some_and(|crap| crap >= 30.0),
913 );
914 finding
915 }
916
917 #[test]
918 fn unchanged_arrow_keeps_its_base_when_only_crap_changes() {
919 let estimated = CoverageSource::Estimated;
924 let measured = CoverageSource::Istanbul;
925 let base = [
926 (122, 36, 37, 1332.0),
927 (284, 26, 28, 702.0),
928 (352, 9, 6, 90.0),
929 (560, 7, 6, 56.0),
930 (637, 30, 42, 930.0),
931 (732, 13, 5, 182.0),
932 (872, 13, 12, 182.0),
933 (889, 6, 2, 42.0),
934 (945, 5, 3, 30.0),
935 (972, 16, 4, 272.0),
936 (1017, 11, 13, 132.0),
937 (1184, 14, 15, 210.0),
938 (1202, 12, 5, 156.0),
939 ]
940 .into_iter()
941 .map(|(line, cyclomatic, cognitive, crap)| {
942 arrow(line, cyclomatic, cognitive, Some(crap), estimated)
943 })
944 .collect();
945 let head = [
946 arrow(128, 36, 37, Some(36.0), measured),
947 arrow(294, 27, 29, None, measured),
948 arrow(663, 30, 42, Some(30.0), measured),
949 ];
950 let classes = attribution(base, &head);
951 assert!(!classes[0].introduced);
952 assert!(!classes[2].introduced, "{:?}", classes[2]);
953 assert!(classes[2].worsened_metrics.is_empty());
954 }
955
956 #[test]
957 fn crap_growth_of_an_unchanged_arrow_still_compares() {
958 let measured = CoverageSource::Istanbul;
959 let classes = attribution(
960 vec![
961 arrow(10, 9, 6, Some(40.0), measured),
962 arrow(40, 30, 42, Some(30.0), measured),
963 ],
964 &[arrow(40, 30, 42, Some(60.0), measured)],
965 );
966 assert_eq!(
967 classes[0].worsened_metrics,
968 [metric(WorsenedMetricKind::Crap, 30.0, 60.0)]
969 );
970 }
971
972 fn with_lines(mut finding: HealthFinding, line_count: u32) -> HealthFinding {
973 finding.violation.line_count = line_count;
974 finding
975 }
976
977 fn attribution(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<ComplexityClass> {
978 classify_complexity_attribution(head, &root(), &baseline(base))
979 }
980
981 fn metric(kind: WorsenedMetricKind, base: f64, head: f64) -> WorsenedMetric {
982 WorsenedMetric {
983 metric: kind,
984 base,
985 head,
986 }
987 }
988
989 #[test]
990 fn grown_line_count_is_worsened_not_introduced() {
991 let classes = attribution(
992 vec![with_lines(finding("f", 1, 17, 16), 80)],
993 &[with_lines(finding("f", 1, 17, 16), 300)],
994 );
995 assert!(!classes[0].introduced);
996 assert!(classes[0].is_worsened());
997 assert_eq!(
998 classes[0].worsened_metrics,
999 [metric(WorsenedMetricKind::LineCount, 80.0, 300.0)]
1000 );
1001 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1002 }
1003
1004 #[test]
1005 fn grown_metric_that_the_finding_does_not_exceed_is_worsened() {
1006 let classes = attribution(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]);
1008 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1009 assert_eq!(
1010 classes[0].worsened_metrics,
1011 [metric(WorsenedMetricKind::Cognitive, 5.0, 9.0)]
1012 );
1013 }
1014
1015 #[test]
1016 fn grown_exceeded_metric_in_the_same_tier_is_worsened() {
1017 let classes = attribution(vec![finding("f", 1, 100, 16)], &[finding("f", 1, 103, 16)]);
1019 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1020 assert!(classes[0].exceeded_metric_grew);
1021 assert_eq!(
1022 classes[0].worsened_metrics,
1023 [metric(WorsenedMetricKind::Cyclomatic, 100.0, 103.0)]
1024 );
1025 let below = attribution(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]);
1027 assert!(!below[0].exceeded_metric_grew);
1028 }
1029
1030 #[test]
1031 fn severity_tier_uses_the_base_values_not_todays_thresholds() {
1032 let mut head = finding("f", 1, 18, 10);
1036 head.violation.exceeded = ExceededThreshold::Both;
1037 assert_eq!(
1038 classify(vec![finding("f", 1, 17, 10)], &[head.clone()]),
1039 [false]
1040 );
1041 head.violation.cognitive = 11;
1043 assert_eq!(classify(vec![finding("f", 1, 17, 10)], &[head]), [true]);
1044 }
1045
1046 #[test]
1047 fn measured_crap_tier_crossing_is_introduced() {
1048 let istanbul = Some(CoverageSource::Istanbul);
1049 let mut base = crap_finding(Some(40.0), istanbul);
1050 base.violation.severity = FindingSeverity::Moderate;
1051 let mut head = crap_finding(Some(120.0), istanbul);
1052 head.violation.severity = FindingSeverity::Critical;
1053 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
1054 let estimated = Some(CoverageSource::Estimated);
1056 base.violation.coverage_source = estimated;
1057 head.violation.coverage_source = estimated;
1058 assert_eq!(classify(vec![base], &[head]), [false]);
1059 }
1060
1061 #[test]
1062 fn grown_exceeded_metric_is_introduced_with_values() {
1063 let classes = attribution(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 40, 16)]);
1064 assert_eq!(
1065 classes[0].attribution(),
1066 AuditFindingAttribution::Introduced
1067 );
1068 assert_eq!(
1069 classes[0].worsened_metrics,
1070 [metric(WorsenedMetricKind::Cyclomatic, 17.0, 40.0)]
1071 );
1072 assert!(!classes[0].is_worsened());
1073 }
1074
1075 #[test]
1076 fn improved_and_moved_functions_are_inherited() {
1077 let improved = attribution(
1078 vec![with_lines(finding("f", 1, 30, 30), 90)],
1079 &[with_lines(finding("f", 1, 20, 18), 60)],
1080 );
1081 assert_eq!(
1082 improved[0].attribution(),
1083 AuditFindingAttribution::Inherited
1084 );
1085 assert!(improved[0].worsened_metrics.is_empty());
1086 let moved = attribution(vec![finding("f", 1, 17, 16)], &[finding("f", 400, 17, 16)]);
1087 assert_eq!(moved[0].attribution(), AuditFindingAttribution::Inherited);
1088 }
1089
1090 #[test]
1091 fn new_function_is_introduced_without_values() {
1092 let classes = attribution(vec![finding("f", 1, 17, 16)], &[finding("g", 1, 17, 16)]);
1093 assert_eq!(
1094 classes[0].attribution(),
1095 AuditFindingAttribution::Introduced
1096 );
1097 assert!(classes[0].worsened_metrics.is_empty());
1098 }
1099
1100 #[test]
1101 fn crap_compares_only_when_both_sides_have_a_score() {
1102 let mut head = finding("f", 1, 20, 5);
1103 head.violation.crap = Some(50.0);
1104 let classes = attribution(vec![finding("f", 1, 20, 5)], std::slice::from_ref(&head));
1105 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1106 let mut base = finding("f", 1, 20, 5);
1107 base.violation.crap = Some(40.0);
1108 let classes = attribution(vec![base], &[head]);
1109 assert_eq!(
1110 classes[0].worsened_metrics,
1111 [metric(WorsenedMetricKind::Crap, 40.0, 50.0)]
1112 );
1113 }
1114
1115 #[test]
1116 fn same_named_findings_prefer_the_base_with_equal_line_count() {
1117 let base = vec![
1119 with_lines(finding("run", 1, 17, 16), 10),
1120 with_lines(finding("run", 30, 17, 16), 40),
1121 ];
1122 let head = [
1123 with_lines(finding("run", 1, 17, 16), 40),
1124 with_lines(finding("run", 50, 17, 16), 25),
1125 ];
1126 let classes = attribution(base, &head);
1127 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1130 assert_eq!(classes[1].attribution(), AuditFindingAttribution::Worsened);
1131 }
1132
1133 #[test]
1134 fn an_exact_match_of_a_later_head_is_not_taken_by_an_earlier_head() {
1135 let base = vec![
1138 with_lines(finding("run", 1, 17, 16), 40),
1139 with_lines(finding("run", 50, 17, 16), 10),
1140 ];
1141 let head = [
1142 with_lines(finding("run", 1, 17, 16), 30),
1143 with_lines(finding("run", 20, 17, 16), 40),
1144 ];
1145 let classes = attribution(base, &head);
1146 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1147 assert_eq!(
1148 classes[0].worsened_metrics,
1149 [metric(WorsenedMetricKind::LineCount, 10.0, 30.0)]
1150 );
1151 assert_eq!(classes[1].attribution(), AuditFindingAttribution::Inherited);
1152 assert!(classes[1].worsened_metrics.is_empty());
1153 }
1154
1155 #[test]
1156 fn small_line_growth_is_not_worsened() {
1157 let classes = attribution(
1159 vec![with_lines(finding("f", 1, 17, 16), 80)],
1160 &[with_lines(finding("f", 1, 17, 16), 84)],
1161 );
1162 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1163 assert!(classes[0].worsened_metrics.is_empty());
1164 }
1165
1166 #[test]
1167 fn line_growth_needs_five_lines_at_any_function_size() {
1168 let short = attribution(
1170 vec![with_lines(finding("f", 1, 17, 16), 20)],
1171 &[with_lines(finding("f", 1, 17, 16), 24)],
1172 );
1173 assert_eq!(short[0].attribution(), AuditFindingAttribution::Inherited);
1174 let grown = attribution(
1175 vec![with_lines(finding("f", 1, 17, 16), 20)],
1176 &[with_lines(finding("f", 1, 17, 16), 25)],
1177 );
1178 assert_eq!(grown[0].attribution(), AuditFindingAttribution::Worsened);
1179 let long = attribution(
1181 vec![with_lines(finding("f", 1, 17, 16), 300)],
1182 &[with_lines(finding("f", 1, 17, 16), 309)],
1183 );
1184 assert_eq!(long[0].attribution(), AuditFindingAttribution::Worsened);
1185 }
1186
1187 #[test]
1188 fn a_base_with_the_same_values_keeps_the_finding_inherited() {
1189 let primary = baseline(vec![with_lines(finding("f", 1, 17, 16), 20)]);
1190 let merged = baseline(vec![with_lines(finding("f", 1, 17, 16), 60)]);
1191 let head = [with_lines(finding("f", 1, 17, 16), 60)];
1192 let classes =
1193 classify_complexity_attribution_against_all(&head, &root(), [&primary, &merged]);
1194 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1195 let classes = classify_complexity_attribution_against_all(&head, &root(), [&primary]);
1196 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1197 }
1198
1199 #[test]
1200 fn rename_moves_the_base_findings() {
1201 let base = baseline(vec![finding("f", 1, 17, 16)]);
1202 let renames =
1203 FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
1204 let remapped = remap_complexity_baseline_for_renames(base, &renames);
1205 assert!(remapped.contains_key("complexity:src/moved.js:f"));
1206 assert!(!remapped.contains_key("complexity:src/index.js:f"));
1207 }
1208
1209 #[test]
1210 fn ledger_keys_count_same_named_findings_separately() {
1211 let keys = complexity_ledger_keys(
1212 &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
1213 &root(),
1214 );
1215 assert_eq!(
1216 keys,
1217 [
1218 "complexity:src/index.js:run",
1219 "complexity:src/index.js:run#2"
1220 ]
1221 );
1222 }
1223}