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
100#[derive(Debug, Clone, PartialEq, Default)]
102pub struct ComplexityClass {
103 pub introduced: bool,
106 pub worsened_metrics: Vec<WorsenedMetric>,
109 pub exceeded_metric_grew: bool,
113}
114
115impl ComplexityClass {
116 #[must_use]
119 pub fn is_worsened(&self) -> bool {
120 !self.introduced && !self.worsened_metrics.is_empty()
121 }
122
123 #[must_use]
125 pub fn attribution(&self) -> AuditFindingAttribution {
126 if self.introduced {
127 AuditFindingAttribution::Introduced
128 } else if self.worsened_metrics.is_empty() {
129 AuditFindingAttribution::Inherited
130 } else {
131 AuditFindingAttribution::Worsened
132 }
133 }
134
135 pub fn clear_worsened_for_off(&mut self) {
140 if !self.introduced && !self.exceeded_metric_grew {
141 self.worsened_metrics.clear();
142 }
143 }
144
145 fn of_match(
146 head: &fallow_output::ComplexityViolation,
147 base: Option<&ComplexityMetrics>,
148 ) -> Self {
149 base.map_or(
150 Self {
151 introduced: true,
152 worsened_metrics: Vec::new(),
153 exceeded_metric_grew: false,
154 },
155 |base| {
156 let exceeded_metric_grew = exceeded_metric_increased(head, base);
157 Self {
158 introduced: newly_exceeded_metric_grew(head, base)
159 || (exceeded_metric_grew && severity_tier_rose(head, base)),
160 worsened_metrics: grown_metrics(head, base),
161 exceeded_metric_grew,
162 }
163 },
164 )
165 }
166}
167
168fn grown_metrics(
174 head: &fallow_output::ComplexityViolation,
175 base: &ComplexityMetrics,
176) -> Vec<WorsenedMetric> {
177 let mut grown = Vec::new();
178 let mut push = |metric, base: f64, head: f64| {
179 if head > base {
180 grown.push(WorsenedMetric { metric, base, head });
181 }
182 };
183 push(
184 WorsenedMetricKind::Cyclomatic,
185 f64::from(base.cyclomatic),
186 f64::from(head.cyclomatic),
187 );
188 push(
189 WorsenedMetricKind::Cognitive,
190 f64::from(base.cognitive),
191 f64::from(head.cognitive),
192 );
193 if let (Some(base_crap), Some(head_crap)) = (base.crap, head.crap) {
194 push(WorsenedMetricKind::Crap, base_crap, head_crap);
195 }
196 if line_count_grew(base.line_count, head.line_count) {
197 grown.push(WorsenedMetric {
198 metric: WorsenedMetricKind::LineCount,
199 base: f64::from(base.line_count),
200 head: f64::from(head.line_count),
201 });
202 }
203 grown
204}
205
206const LINE_COUNT_MIN_GROWTH_LINES: u32 = 5;
208
209fn line_count_grew(base: u32, head: u32) -> bool {
217 head.saturating_sub(base) >= LINE_COUNT_MIN_GROWTH_LINES
218}
219
220pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
223
224#[must_use]
226pub fn complexity_baseline(
227 report: &fallow_output::HealthReport,
228 root: &Path,
229) -> ComplexityBaseline {
230 let mut baseline = ComplexityBaseline::default();
231 for finding in &report.findings {
232 baseline
233 .entry(health_finding_key(finding, root))
234 .or_default()
235 .push(ComplexityMetrics::of(finding));
236 }
237 sort_by_line(&mut baseline);
238 baseline
239}
240
241#[must_use]
243pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
244 baseline.keys().cloned().collect()
245}
246
247#[must_use]
253#[expect(
254 clippy::implicit_hasher,
255 reason = "fallow standardizes on FxHashMap across audit attribution keys"
256)]
257pub fn remap_complexity_baseline_for_renames(
258 baseline: ComplexityBaseline,
259 renames: &FxHashMap<String, String>,
260) -> ComplexityBaseline {
261 let mut remapped = ComplexityBaseline::default();
262 for (key, findings) in baseline {
263 remapped
264 .entry(remap_key_for_renames(&key, renames))
265 .or_default()
266 .extend(findings);
267 }
268 sort_by_line(&mut remapped);
269 remapped
270}
271
272fn sort_by_line(baseline: &mut ComplexityBaseline) {
273 for findings in baseline.values_mut() {
274 findings.sort_by_key(|metrics| metrics.line);
275 }
276}
277
278fn exceeded_metric_increased(
283 head: &fallow_output::ComplexityViolation,
284 base: &ComplexityMetrics,
285) -> bool {
286 let exceeded = head.exceeded;
287 (exceeded.includes_cyclomatic() && head.cyclomatic > base.cyclomatic)
288 || (exceeded.includes_cognitive() && head.cognitive > base.cognitive)
289 || (exceeded.includes_crap() && measured_crap_grew(head, base))
290}
291
292fn newly_exceeded_metric_grew(
296 head: &fallow_output::ComplexityViolation,
297 base: &ComplexityMetrics,
298) -> bool {
299 let (now, before) = (head.exceeded, base.exceeded);
300 (now.includes_cyclomatic()
301 && !before.includes_cyclomatic()
302 && head.cyclomatic > base.cyclomatic)
303 || (now.includes_cognitive()
304 && !before.includes_cognitive()
305 && head.cognitive > base.cognitive)
306 || (now.includes_crap() && !before.includes_crap() && measured_crap_grew(head, base))
307}
308
309fn measured_crap_grew(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
313 crap_is_comparable(head, base)
314 && matches!((head.crap, base.crap), (Some(head), Some(base)) if head > base)
315}
316
317fn crap_is_comparable(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
318 head.crap.is_some() && base.crap.is_some() && !crap_is_estimated(head) && !base.crap_estimated
319}
320
321fn severity_tier_rose(head: &fallow_output::ComplexityViolation, base: &ComplexityMetrics) -> bool {
327 if crap_is_comparable(head, base) {
328 return head.severity > base.severity;
329 }
330 complexity_tier(head.cognitive, head.cyclomatic)
331 > complexity_tier(base.cognitive, base.cyclomatic)
332}
333
334fn complexity_tier(cognitive: u16, cyclomatic: u16) -> FindingSeverity {
335 compute_finding_severity(
336 cognitive,
337 cyclomatic,
338 None,
339 DEFAULT_COGNITIVE_HIGH,
340 DEFAULT_COGNITIVE_CRITICAL,
341 DEFAULT_CYCLOMATIC_HIGH,
342 DEFAULT_CYCLOMATIC_CRITICAL,
343 )
344}
345
346#[must_use]
349pub const fn crap_is_estimated(finding: &fallow_output::ComplexityViolation) -> bool {
350 matches!(
351 finding.coverage_source,
352 Some(CoverageSource::Estimated | CoverageSource::EstimatedComponentInherited)
353 )
354}
355
356#[must_use]
359pub const fn exceeds_only_estimated_crap(finding: &fallow_output::ComplexityViolation) -> bool {
360 matches!(finding.exceeded, ExceededThreshold::Crap) && crap_is_estimated(finding)
361}
362
363pub fn demote_estimated_crap_gates(findings: &mut [fallow_output::HealthFinding]) {
371 for finding in findings {
372 if exceeds_only_estimated_crap(finding)
373 && finding.effective_severity == Some(EffectiveSeverity::Error)
374 {
375 finding.violation.effective_severity = Some(EffectiveSeverity::Warn);
376 }
377 }
378}
379
380pub fn exempt_test_file_gates(findings: &mut [fallow_output::HealthFinding], root: &Path) {
388 for finding in findings {
389 let relative = finding.path.strip_prefix(root).unwrap_or(&finding.path);
390 if fallow_engine::test_paths::is_test_path(root, relative) {
391 finding.violation.effective_severity = Some(EffectiveSeverity::Warn);
392 finding.gate_exemption = Some(AuditGateExemption::TestFile);
393 }
394 }
395}
396
397#[must_use]
400pub fn classify_complexity_findings(
401 findings: &[fallow_output::HealthFinding],
402 root: &Path,
403 base: &ComplexityBaseline,
404) -> Vec<bool> {
405 classify_complexity_attribution(findings, root, base)
406 .into_iter()
407 .map(|class| class.introduced)
408 .collect()
409}
410
411#[must_use]
437pub fn classify_complexity_attribution(
438 findings: &[fallow_output::HealthFinding],
439 root: &Path,
440 base: &ComplexityBaseline,
441) -> Vec<ComplexityClass> {
442 let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
443 for (index, finding) in findings.iter().enumerate() {
444 groups
445 .entry(health_finding_key(finding, root))
446 .or_default()
447 .push(index);
448 }
449 let mut matches: Vec<Option<&ComplexityMetrics>> = vec![None; findings.len()];
450 for (key, mut heads) in groups {
451 let Some(bases) = base.get(&key) else {
452 continue;
453 };
454 heads.sort_by_key(|&index| (findings[index].line, findings[index].col));
455 let mut unmatched_bases: Vec<Option<&ComplexityMetrics>> = bases.iter().map(Some).collect();
456 let rules: [fn(&ComplexityMetrics, &ComplexityMetrics) -> bool; 2] = [
457 ComplexityMetrics::same_values_and_lines,
458 ComplexityMetrics::same_values,
459 ];
460 for rule in rules {
461 for &index in &heads {
462 if matches[index].is_some() {
463 continue;
464 }
465 let head = ComplexityMetrics::of(&findings[index]);
466 if let Some(slot) = unmatched_bases
467 .iter_mut()
468 .find(|slot| slot.is_some_and(|base| rule(base, &head)))
469 {
470 matches[index] = slot.take();
471 }
472 }
473 }
474 let mut remaining_bases = unmatched_bases.into_iter().flatten();
475 for &index in &heads {
476 if matches[index].is_none() {
477 matches[index] = remaining_bases.next();
478 }
479 }
480 }
481 findings
482 .iter()
483 .zip(matches)
484 .map(|(finding, base)| ComplexityClass::of_match(finding, base))
485 .collect()
486}
487
488#[must_use]
492pub fn classify_complexity_findings_against_all<'a>(
493 findings: &[fallow_output::HealthFinding],
494 root: &Path,
495 bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
496) -> Vec<bool> {
497 classify_complexity_attribution_against_all(findings, root, bases)
498 .into_iter()
499 .map(|class| class.introduced)
500 .collect()
501}
502
503#[must_use]
512pub fn classify_complexity_attribution_against_all<'a>(
513 findings: &[fallow_output::HealthFinding],
514 root: &Path,
515 bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
516) -> Vec<ComplexityClass> {
517 let per_base: Vec<Vec<ComplexityClass>> = bases
518 .into_iter()
519 .map(|base| classify_complexity_attribution(findings, root, base))
520 .collect();
521 (0..findings.len())
522 .map(|index| {
523 let classes = per_base.iter().map(|classes| &classes[index]);
524 let inherited = classes
525 .clone()
526 .filter(|class| !class.introduced)
527 .min_by_key(|class| class.worsened_metrics.len());
528 inherited.cloned().unwrap_or_else(|| ComplexityClass {
529 introduced: true,
530 exceeded_metric_grew: false,
531 worsened_metrics: classes
532 .map(|class| &class.worsened_metrics)
533 .find(|metrics| !metrics.is_empty())
534 .cloned()
535 .unwrap_or_default(),
536 })
537 })
538 .collect()
539}
540
541#[must_use]
547pub fn complexity_ledger_keys(
548 findings: &[fallow_output::HealthFinding],
549 root: &Path,
550) -> Vec<String> {
551 let mut seen: FxHashMap<String, usize> = FxHashMap::default();
552 findings
553 .iter()
554 .map(|finding| {
555 let key = health_finding_key(finding, root);
556 let occurrence = seen.entry(key.clone()).or_default();
557 *occurrence += 1;
558 if *occurrence == 1 {
559 key
560 } else {
561 format!("{key}#{occurrence}")
562 }
563 })
564 .collect()
565}
566
567#[cfg(test)]
568mod tests {
569 use std::path::PathBuf;
570
571 use fallow_output::{
572 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
573 };
574
575 use super::*;
576
577 const MAX: u16 = 15;
578
579 fn root() -> PathBuf {
580 PathBuf::from("/project")
581 }
582
583 fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
584 HealthFinding::from(ComplexityViolation {
585 path: root().join("src/index.js"),
586 name: name.to_string(),
587 line,
588 col: 0,
589 cyclomatic,
590 cognitive,
591 line_count: 30,
592 param_count: 1,
593 react_hook_count: 0,
594 react_jsx_max_depth: 0,
595 react_prop_count: 0,
596 react_hook_profile: None,
597 exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
598 effective_severity: None,
599 severity: FindingSeverity::High,
600 crap: None,
601 mutation: None,
602 coverage_pct: None,
603 coverage_tier: None,
604 coverage_source: None,
605 inherited_from: None,
606 component_rollup: None,
607 contributions: Vec::new(),
608 effective_thresholds: None,
609 threshold_source: None,
610 })
611 }
612
613 fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
614 complexity_baseline(
615 &HealthReport {
616 findings,
617 ..HealthReport::default()
618 },
619 &root(),
620 )
621 }
622
623 fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
624 classify_complexity_findings(head, &root(), &baseline(base))
625 }
626
627 #[test]
628 fn identity_key_excludes_category_and_line() {
629 let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
630 assert_eq!(key, "complexity:src/index.js:hotspot");
631 }
632
633 #[test]
634 fn issue_cases() {
635 assert_eq!(
637 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
638 [false]
639 );
640 assert_eq!(
642 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
643 [false]
644 );
645 assert_eq!(
647 classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
648 [false]
649 );
650 assert_eq!(
652 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
653 [false]
654 );
655 assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
657 }
658
659 #[test]
660 fn only_exceeded_metrics_can_introduce() {
661 assert_eq!(
664 classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
665 [false]
666 );
667 assert_eq!(
669 classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
670 [true]
671 );
672 }
673
674 #[test]
675 fn a_finding_that_any_base_has_is_inherited() {
676 let primary = baseline(vec![]);
677 let merged = baseline(vec![finding("f", 1, 17, 16)]);
678 let head = [finding("f", 1, 17, 16), finding("g", 9, 17, 16)];
679 assert_eq!(
680 classify_complexity_findings_against_all(&head, &root(), [&primary, &merged]),
681 [false, true]
682 );
683 assert_eq!(
686 classify_complexity_findings_against_all(
687 &[finding("f", 1, 31, 17)],
688 &root(),
689 [&baseline(vec![finding("f", 1, 17, 16)]), &merged]
690 ),
691 [true]
692 );
693 }
694
695 #[test]
696 fn line_shift_keeps_the_match() {
697 assert_eq!(
698 classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
699 [false]
700 );
701 }
702
703 fn crap_finding(crap: Option<f64>, source: Option<CoverageSource>) -> HealthFinding {
704 let mut finding = finding("f", 1, 16, 5);
705 finding.violation.exceeded = ExceededThreshold::Crap;
706 finding.violation.crap = crap;
707 finding.violation.coverage_source = source;
708 finding.violation.effective_severity = Some(EffectiveSeverity::Error);
709 finding
710 }
711
712 #[test]
713 fn crap_without_base_value_is_not_an_increase() {
714 let head = crap_finding(Some(50.0), Some(CoverageSource::Istanbul));
717 assert_eq!(
718 classify(vec![crap_finding(None, None)], std::slice::from_ref(&head)),
719 [false]
720 );
721 assert_eq!(
723 classify(
724 vec![crap_finding(Some(40.0), Some(CoverageSource::Istanbul))],
725 std::slice::from_ref(&head)
726 ),
727 [false]
728 );
729 assert_eq!(
730 classify(
731 vec![crap_finding(Some(60.0), Some(CoverageSource::Istanbul))],
732 &[head]
733 ),
734 [false]
735 );
736 }
737
738 #[test]
739 fn estimated_crap_growth_is_worsened_not_introduced() {
740 let estimated = Some(CoverageSource::Estimated);
741 let classes = attribution(
742 vec![crap_finding(Some(40.0), estimated)],
743 &[crap_finding(Some(50.0), estimated)],
744 );
745 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
746 assert_eq!(
747 classes[0].worsened_metrics,
748 [metric(WorsenedMetricKind::Crap, 40.0, 50.0)]
749 );
750 let mut base = crap_finding(Some(40.0), estimated);
752 base.violation.exceeded = ExceededThreshold::CognitiveCrap;
753 base.violation.cognitive = 20;
754 let mut head = base.clone();
755 head.violation.cognitive = 21;
756 head.violation.crap = Some(50.0);
757 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [false]);
758 head.violation.cognitive = 25;
760 assert_eq!(classify(vec![base], &[head]), [true]);
761 }
762
763 #[test]
764 fn test_file_findings_get_warn_gate_severity_and_the_exemption() {
765 let project = root().join("tests").join("project");
768 let at = |relative: &str| {
769 let mut finding = finding("f", 1, 16, 5);
770 finding.violation.path = project.join(relative);
771 finding.violation.effective_severity = Some(EffectiveSeverity::Error);
772 finding
773 };
774 let mut findings = [
775 at("src/__tests__/app.js"),
776 at("src/app.spec.ts"),
777 at("src/app.test.tsx"),
778 at("src/__mocks__/api.js"),
779 at("src/app.js"),
780 at("src/latest.js"),
781 ];
782 exempt_test_file_gates(&mut findings, &project);
783 let marks: Vec<_> = findings
784 .iter()
785 .map(|finding| (finding.effective_severity, finding.gate_exemption))
786 .collect();
787 let exempt = (
788 Some(EffectiveSeverity::Warn),
789 Some(AuditGateExemption::TestFile),
790 );
791 let gated = (Some(EffectiveSeverity::Error), None);
792 assert_eq!(marks, [exempt, exempt, exempt, exempt, gated, gated]);
793 }
794
795 #[test]
796 fn estimated_crap_only_findings_get_warn_gate_severity() {
797 let mut both = crap_finding(Some(50.0), Some(CoverageSource::Estimated));
798 both.violation.exceeded = ExceededThreshold::CognitiveCrap;
799 let mut warn_rule = crap_finding(Some(50.0), Some(CoverageSource::Estimated));
800 warn_rule.violation.effective_severity = Some(EffectiveSeverity::Warn);
801 let mut findings = [
802 crap_finding(Some(50.0), Some(CoverageSource::Estimated)),
803 crap_finding(
804 Some(50.0),
805 Some(CoverageSource::EstimatedComponentInherited),
806 ),
807 crap_finding(Some(50.0), Some(CoverageSource::Istanbul)),
808 both,
809 warn_rule,
810 ];
811 demote_estimated_crap_gates(&mut findings);
812 let severities: Vec<_> = findings
813 .iter()
814 .map(|finding| finding.effective_severity)
815 .collect();
816 assert_eq!(
817 severities,
818 [
819 Some(EffectiveSeverity::Warn),
820 Some(EffectiveSeverity::Warn),
821 Some(EffectiveSeverity::Error),
822 Some(EffectiveSeverity::Error),
823 Some(EffectiveSeverity::Warn),
824 ]
825 );
826 }
827
828 #[test]
829 fn same_named_findings_match_unchanged_values_first() {
830 let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
831 assert_eq!(
833 classify(
834 base.clone(),
835 &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
836 ),
837 [false, false]
838 );
839 assert_eq!(
841 classify(base.clone(), &[finding("run", 1, 21, 20)]),
842 [false]
843 );
844 assert_eq!(
846 classify(
847 base.clone(),
848 &[
849 finding("run", 1, 30, 30),
850 finding("run", 20, 17, 16),
851 finding("run", 50, 21, 20)
852 ]
853 ),
854 [true, false, false]
855 );
856 assert_eq!(
858 classify(
859 base.clone(),
860 &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
861 ),
862 [false, false]
863 );
864 assert_eq!(
866 classify(
867 base,
868 &[finding("run", 1, 30, 17), finding("run", 30, 21, 20)]
869 ),
870 [true, false]
871 );
872 }
873
874 fn with_lines(mut finding: HealthFinding, line_count: u32) -> HealthFinding {
875 finding.violation.line_count = line_count;
876 finding
877 }
878
879 fn attribution(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<ComplexityClass> {
880 classify_complexity_attribution(head, &root(), &baseline(base))
881 }
882
883 fn metric(kind: WorsenedMetricKind, base: f64, head: f64) -> WorsenedMetric {
884 WorsenedMetric {
885 metric: kind,
886 base,
887 head,
888 }
889 }
890
891 #[test]
892 fn grown_line_count_is_worsened_not_introduced() {
893 let classes = attribution(
894 vec![with_lines(finding("f", 1, 17, 16), 80)],
895 &[with_lines(finding("f", 1, 17, 16), 300)],
896 );
897 assert!(!classes[0].introduced);
898 assert!(classes[0].is_worsened());
899 assert_eq!(
900 classes[0].worsened_metrics,
901 [metric(WorsenedMetricKind::LineCount, 80.0, 300.0)]
902 );
903 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
904 }
905
906 #[test]
907 fn grown_metric_that_the_finding_does_not_exceed_is_worsened() {
908 let classes = attribution(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]);
910 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
911 assert_eq!(
912 classes[0].worsened_metrics,
913 [metric(WorsenedMetricKind::Cognitive, 5.0, 9.0)]
914 );
915 }
916
917 #[test]
918 fn grown_exceeded_metric_in_the_same_tier_is_worsened() {
919 let classes = attribution(vec![finding("f", 1, 100, 16)], &[finding("f", 1, 103, 16)]);
921 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
922 assert!(classes[0].exceeded_metric_grew);
923 assert_eq!(
924 classes[0].worsened_metrics,
925 [metric(WorsenedMetricKind::Cyclomatic, 100.0, 103.0)]
926 );
927 let below = attribution(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]);
929 assert!(!below[0].exceeded_metric_grew);
930 }
931
932 #[test]
933 fn severity_tier_uses_the_base_values_not_todays_thresholds() {
934 let mut head = finding("f", 1, 18, 10);
938 head.violation.exceeded = ExceededThreshold::Both;
939 assert_eq!(
940 classify(vec![finding("f", 1, 17, 10)], &[head.clone()]),
941 [false]
942 );
943 head.violation.cognitive = 11;
945 assert_eq!(classify(vec![finding("f", 1, 17, 10)], &[head]), [true]);
946 }
947
948 #[test]
949 fn measured_crap_tier_crossing_is_introduced() {
950 let istanbul = Some(CoverageSource::Istanbul);
951 let mut base = crap_finding(Some(40.0), istanbul);
952 base.violation.severity = FindingSeverity::Moderate;
953 let mut head = crap_finding(Some(120.0), istanbul);
954 head.violation.severity = FindingSeverity::Critical;
955 assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
956 let estimated = Some(CoverageSource::Estimated);
958 base.violation.coverage_source = estimated;
959 head.violation.coverage_source = estimated;
960 assert_eq!(classify(vec![base], &[head]), [false]);
961 }
962
963 #[test]
964 fn grown_exceeded_metric_is_introduced_with_values() {
965 let classes = attribution(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 40, 16)]);
966 assert_eq!(
967 classes[0].attribution(),
968 AuditFindingAttribution::Introduced
969 );
970 assert_eq!(
971 classes[0].worsened_metrics,
972 [metric(WorsenedMetricKind::Cyclomatic, 17.0, 40.0)]
973 );
974 assert!(!classes[0].is_worsened());
975 }
976
977 #[test]
978 fn improved_and_moved_functions_are_inherited() {
979 let improved = attribution(
980 vec![with_lines(finding("f", 1, 30, 30), 90)],
981 &[with_lines(finding("f", 1, 20, 18), 60)],
982 );
983 assert_eq!(
984 improved[0].attribution(),
985 AuditFindingAttribution::Inherited
986 );
987 assert!(improved[0].worsened_metrics.is_empty());
988 let moved = attribution(vec![finding("f", 1, 17, 16)], &[finding("f", 400, 17, 16)]);
989 assert_eq!(moved[0].attribution(), AuditFindingAttribution::Inherited);
990 }
991
992 #[test]
993 fn new_function_is_introduced_without_values() {
994 let classes = attribution(vec![finding("f", 1, 17, 16)], &[finding("g", 1, 17, 16)]);
995 assert_eq!(
996 classes[0].attribution(),
997 AuditFindingAttribution::Introduced
998 );
999 assert!(classes[0].worsened_metrics.is_empty());
1000 }
1001
1002 #[test]
1003 fn crap_compares_only_when_both_sides_have_a_score() {
1004 let mut head = finding("f", 1, 20, 5);
1005 head.violation.crap = Some(50.0);
1006 let classes = attribution(vec![finding("f", 1, 20, 5)], std::slice::from_ref(&head));
1007 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1008 let mut base = finding("f", 1, 20, 5);
1009 base.violation.crap = Some(40.0);
1010 let classes = attribution(vec![base], &[head]);
1011 assert_eq!(
1012 classes[0].worsened_metrics,
1013 [metric(WorsenedMetricKind::Crap, 40.0, 50.0)]
1014 );
1015 }
1016
1017 #[test]
1018 fn same_named_findings_prefer_the_base_with_equal_line_count() {
1019 let base = vec![
1021 with_lines(finding("run", 1, 17, 16), 10),
1022 with_lines(finding("run", 30, 17, 16), 40),
1023 ];
1024 let head = [
1025 with_lines(finding("run", 1, 17, 16), 40),
1026 with_lines(finding("run", 50, 17, 16), 25),
1027 ];
1028 let classes = attribution(base, &head);
1029 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1032 assert_eq!(classes[1].attribution(), AuditFindingAttribution::Worsened);
1033 }
1034
1035 #[test]
1036 fn an_exact_match_of_a_later_head_is_not_taken_by_an_earlier_head() {
1037 let base = vec![
1040 with_lines(finding("run", 1, 17, 16), 40),
1041 with_lines(finding("run", 50, 17, 16), 10),
1042 ];
1043 let head = [
1044 with_lines(finding("run", 1, 17, 16), 30),
1045 with_lines(finding("run", 20, 17, 16), 40),
1046 ];
1047 let classes = attribution(base, &head);
1048 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1049 assert_eq!(
1050 classes[0].worsened_metrics,
1051 [metric(WorsenedMetricKind::LineCount, 10.0, 30.0)]
1052 );
1053 assert_eq!(classes[1].attribution(), AuditFindingAttribution::Inherited);
1054 assert!(classes[1].worsened_metrics.is_empty());
1055 }
1056
1057 #[test]
1058 fn small_line_growth_is_not_worsened() {
1059 let classes = attribution(
1061 vec![with_lines(finding("f", 1, 17, 16), 80)],
1062 &[with_lines(finding("f", 1, 17, 16), 84)],
1063 );
1064 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1065 assert!(classes[0].worsened_metrics.is_empty());
1066 }
1067
1068 #[test]
1069 fn line_growth_needs_five_lines_at_any_function_size() {
1070 let short = attribution(
1072 vec![with_lines(finding("f", 1, 17, 16), 20)],
1073 &[with_lines(finding("f", 1, 17, 16), 24)],
1074 );
1075 assert_eq!(short[0].attribution(), AuditFindingAttribution::Inherited);
1076 let grown = attribution(
1077 vec![with_lines(finding("f", 1, 17, 16), 20)],
1078 &[with_lines(finding("f", 1, 17, 16), 25)],
1079 );
1080 assert_eq!(grown[0].attribution(), AuditFindingAttribution::Worsened);
1081 let long = attribution(
1083 vec![with_lines(finding("f", 1, 17, 16), 300)],
1084 &[with_lines(finding("f", 1, 17, 16), 309)],
1085 );
1086 assert_eq!(long[0].attribution(), AuditFindingAttribution::Worsened);
1087 }
1088
1089 #[test]
1090 fn a_base_with_the_same_values_keeps_the_finding_inherited() {
1091 let primary = baseline(vec![with_lines(finding("f", 1, 17, 16), 20)]);
1092 let merged = baseline(vec![with_lines(finding("f", 1, 17, 16), 60)]);
1093 let head = [with_lines(finding("f", 1, 17, 16), 60)];
1094 let classes =
1095 classify_complexity_attribution_against_all(&head, &root(), [&primary, &merged]);
1096 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Inherited);
1097 let classes = classify_complexity_attribution_against_all(&head, &root(), [&primary]);
1098 assert_eq!(classes[0].attribution(), AuditFindingAttribution::Worsened);
1099 }
1100
1101 #[test]
1102 fn rename_moves_the_base_findings() {
1103 let base = baseline(vec![finding("f", 1, 17, 16)]);
1104 let renames =
1105 FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
1106 let remapped = remap_complexity_baseline_for_renames(base, &renames);
1107 assert!(remapped.contains_key("complexity:src/moved.js:f"));
1108 assert!(!remapped.contains_key("complexity:src/index.js:f"));
1109 }
1110
1111 #[test]
1112 fn ledger_keys_count_same_named_findings_separately() {
1113 let keys = complexity_ledger_keys(
1114 &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
1115 &root(),
1116 );
1117 assert_eq!(
1118 keys,
1119 [
1120 "complexity:src/index.js:run",
1121 "complexity:src/index.js:run#2"
1122 ]
1123 );
1124 }
1125}