Skip to main content

fallow_api/
audit_complexity.rs

1//! New-only attribution of complexity findings.
2//!
3//! A complexity finding is identified by its path and function name. The
4//! exceeded category and the line are not part of the identity, so a line
5//! shift or a category change keeps the match with the base finding. The
6//! metric values then decide the attribution. A head finding is introduced
7//! when:
8//!
9//! - no base finding matches it,
10//! - it exceeds a metric that the base finding did not exceed, and that metric
11//!   grew (see [`newly_exceeded_metric_grew`]), or
12//! - a metric that it exceeds grew, and its severity tier is higher than the
13//!   tier of the base finding (see [`severity_tier_rose`]).
14//!
15//! The exceeded flags and the tier of the base finding come from the base run,
16//! so a threshold override in the same change cannot make an old finding
17//! introduced. Unchanged and decreased metrics stay inherited, also when the
18//! exceeded category changes (#3277).
19//!
20//! A matched finding that is not introduced is worsened when its cyclomatic,
21//! cognitive or CRAP value is higher than in its matched base finding. Growth
22//! of a metric that the base finding already exceeded, in the same tier, is
23//! worsened too. A line count must grow by a minimum (see
24//! [`line_count_grew`]). The `audit.worsened` setting decides how a worsened
25//! finding affects the verdict.
26//!
27//! A CRAP score from estimated coverage does not make a finding introduced.
28//! Under the `new-only` gate, a finding that exceeds only that CRAP score gets
29//! the gate severity `warn` (see [`demote_estimated_crap_gates`]).
30//!
31//! Under every gate, a finding in a test file gets the gate severity `warn`
32//! and the gate exemption `test-file` (see [`exempt_test_file_gates`]). The
33//! attribution of the finding does not change.
34
35use 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/// Metric values of one base complexity finding.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ComplexityMetrics {
51    /// 1-based line of the function. Only orders same-named findings.
52    pub line: u32,
53    /// Cyclomatic complexity.
54    pub cyclomatic: u16,
55    /// Cognitive complexity.
56    pub cognitive: u16,
57    /// CRAP score, when coverage data exists.
58    pub crap: Option<f64>,
59    /// Lines of code in the function body.
60    pub line_count: u32,
61    /// The thresholds that the finding exceeded in its own run.
62    pub exceeded: ExceededThreshold,
63    /// The severity tier of the finding in its own run.
64    pub severity: FindingSeverity,
65    /// Whether the CRAP score comes from estimated coverage.
66    pub crap_estimated: bool,
67}
68
69impl ComplexityMetrics {
70    /// The metric values of one finding.
71    #[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    /// Whether both findings have the same complexity values. The line and
86    /// the line count are not compared.
87    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    /// Whether both findings have the same complexity values and the same
94    /// line count.
95    fn same_values_and_lines(&self, other: &Self) -> bool {
96        self.same_values(other) && self.line_count == other.line_count
97    }
98}
99
100/// Attribution of one head complexity finding against the base findings.
101#[derive(Debug, Clone, PartialEq, Default)]
102pub struct ComplexityClass {
103    /// `true` when no base finding matches, when a newly exceeded metric
104    /// grew, or when growth moved the finding to a higher severity tier.
105    pub introduced: bool,
106    /// The measured metrics that are higher than in the matched base finding.
107    /// Empty when no base finding matches or when no metric grew.
108    pub worsened_metrics: Vec<WorsenedMetric>,
109    /// `true` when a metric that the finding exceeds grew. Before this rule,
110    /// that growth made the finding introduced, so `audit.worsened: off`
111    /// keeps it worsened (see [`Self::clear_worsened_for_off`]).
112    pub exceeded_metric_grew: bool,
113}
114
115impl ComplexityClass {
116    /// `true` when a base finding matches, the finding is not introduced, and
117    /// a measured metric grew.
118    #[must_use]
119    pub fn is_worsened(&self) -> bool {
120        !self.introduced && !self.worsened_metrics.is_empty()
121    }
122
123    /// The audit-mode class of the finding.
124    #[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    /// Clear the grown metrics for `audit.worsened: off`. Growth only in
136    /// metrics that the finding does not exceed then stays inherited. Growth
137    /// of an exceeded metric stays worsened, so it gives at least `warn` and
138    /// does not disappear from the report.
139    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
168/// The measured metrics of `head` that are higher than in `base`.
169///
170/// The line count must grow by a minimum, see [`line_count_grew`]. CRAP
171/// compares only when both sides have a score, because a score that
172/// only one side has reflects the coverage input, not the code.
173fn 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
206/// The smallest line count growth, in lines, that makes a finding worsened.
207const LINE_COUNT_MIN_GROWTH_LINES: u32 = 5;
208
209/// Whether the line count grew by at least [`LINE_COUNT_MIN_GROWTH_LINES`]
210/// lines. The floor is fixed and does not scale with the function size, so
211/// growth in a large function is not hidden.
212///
213/// The line count is the physical span of the function, so comments and blank
214/// lines in the body count. The minimum keeps a comment or a formatter rewrap
215/// from making a finding worsened.
216fn line_count_grew(base: u32, head: u32) -> bool {
217    head.saturating_sub(base) >= LINE_COUNT_MIN_GROWTH_LINES
218}
219
220/// Base complexity findings by identity key (`complexity:<path>:<function>`),
221/// each list sorted by line.
222pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
223
224/// The base complexity findings of a health report, by identity key.
225#[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/// The identity keys of a complexity baseline.
242#[must_use]
243pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
244    baseline.keys().cloned().collect()
245}
246
247/// Relocate the base findings of renamed files onto their head paths.
248///
249/// `renames` maps base-relative old paths to head-relative new paths, as in
250/// [`crate::audit_keys::remap_keys_for_renames`]. When two keys merge, their findings join
251/// one list, sorted by line.
252#[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
278/// Whether `head` is worse than `base` in a metric that `head` exceeds.
279///
280/// CRAP counts only when both sides have a measured score (see
281/// [`measured_crap_grew`]).
282fn 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
292/// Whether `head` exceeds a metric that `base` did not exceed in its own run,
293/// and that metric grew. A metric that crosses its limit only because a
294/// threshold changed does not grow, so it stays inherited.
295fn 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
309/// Whether both sides have a CRAP score from measured coverage and the head
310/// score is higher. A score that only one side has, or a score from estimated
311/// coverage, reflects the coverage input, not the code.
312fn 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
321/// Whether the severity tier of `head` is higher than the tier of `base`.
322///
323/// The tiers use fixed limits, not the configured thresholds. The CRAP score
324/// takes part only when both sides have a measured score; else both tiers come
325/// from cyclomatic and cognitive complexity only.
326fn 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/// Whether the CRAP score of the finding comes from estimated coverage, not
347/// from a measured coverage map (`--coverage` or runtime coverage).
348#[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/// Whether CRAP from estimated coverage is the only threshold that the
357/// finding exceeds.
358#[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
363/// Give the gate severity `warn` to each finding that exceeds only CRAP from
364/// estimated coverage, for the `new-only` gate.
365///
366/// The estimate comes from static test reachability, so it does not measure
367/// the change. The finding stays in the report, and the verdict reads `warn`
368/// for it. A finding that also exceeds cyclomatic or cognitive complexity
369/// keeps its gate severity. Measured coverage keeps the gate severity too.
370pub 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
380/// Give the gate severity `warn` to each finding in a test file, and mark it
381/// with [`AuditGateExemption::TestFile`].
382///
383/// The shared test-path classifier ([`fallow_engine::test_paths::is_test_path`])
384/// decides what a test file is: specs, test directories, end-to-end suites,
385/// mocks and fixtures. The finding stays in the report. `fallow health` does
386/// not call this, so it keeps the rule severity there.
387pub 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/// Classify each head complexity finding as introduced (`true`) or inherited
398/// (`false`), in report order. See [`classify_complexity_attribution`].
399#[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/// Classify each head complexity finding against one base, in report order.
412///
413/// Head and base findings match by identity key. When one key holds several
414/// findings on one side (same-named class methods or anonymous functions in
415/// one file), the match is ambiguous. This conservative rule then applies, per
416/// key:
417///
418/// 1. Each head finding, in line order, takes an unmatched base finding with
419///    the same complexity values and the same line count.
420/// 2. Each remaining head finding, in line order, takes an unmatched base
421///    finding with the same complexity values. Rules 1 and 2 keep unchanged
422///    findings inherited when a same-named finding is added, removed or
423///    edited. Rule 1 runs for all head findings first, so an earlier head
424///    finding cannot take the exact match of a later one.
425/// 3. The remaining head findings, in line order, take the remaining base
426///    findings, in line order. Each pair compares the exceeded metrics.
427/// 4. A head finding without a base finding is introduced.
428///
429/// Every base finding matches at most one head finding, so a new same-named
430/// finding cannot hide behind an existing one. The rule does not identify
431/// functions beyond path and name (see #2010). Two same-named findings that
432/// swap their metric values in one change stay inherited.
433///
434/// Each matched pair also lists the measured metrics that grew, so an
435/// inherited finding that got worse is visible.
436#[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/// Classify each head complexity finding against several bases, in report
489/// order. A finding is introduced only when it is introduced against every
490/// base, so a finding that one base already has is inherited.
491#[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/// Classify each head complexity finding against several bases, in report
504/// order.
505///
506/// A finding is introduced only when it is introduced against every base. A
507/// finding that is not introduced takes the smallest list of grown metrics of
508/// the bases that it is not introduced against, so a finding that one base
509/// already has with the same values is inherited, not worsened. An introduced
510/// finding keeps the grown metrics of the first base that matches it.
511#[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/// The ledger key of each complexity finding, in report order.
542///
543/// A repeated identity key gets an occurrence suffix, so each finding counts
544/// once in the attribution counts. Without the suffix, same-named findings
545/// collapse to one count.
546#[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        // Unchanged.
636        assert_eq!(
637            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
638            [false]
639        );
640        // Increased in the same severity tier: worsened, not introduced.
641        assert_eq!(
642            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
643            [false]
644        );
645        // Decreased, same category.
646        assert_eq!(
647            classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
648            [false]
649        );
650        // Decreased, category change from both to cyclomatic.
651        assert_eq!(
652            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
653            [false]
654        );
655        // New threshold crossing.
656        assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
657    }
658
659    #[test]
660    fn only_exceeded_metrics_can_introduce() {
661        // Cognitive grows but stays under the limit: only cyclomatic is
662        // exceeded, and it did not grow.
663        assert_eq!(
664            classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
665            [false]
666        );
667        // A category change to a newly exceeded metric that grew.
668        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        // A finding that moved to a higher tier against every base stays
684        // introduced.
685        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        // A base without a CRAP score ran without that coverage input. The
715        // code did not grow.
716        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        // Measured CRAP growth in the same tier is worsened.
722        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        // A grown cognitive value that the base already exceeds is worsened.
751        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        // A grown cognitive value that reaches the next tier is introduced.
759        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        // The project itself lives below a `tests` directory. Only the path
766        // below the project root decides.
767        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        // Unchanged.
832        assert_eq!(
833            classify(
834                base.clone(),
835                &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
836            ),
837            [false, false]
838        );
839        // First one removed: the second one keeps its own base finding.
840        assert_eq!(
841            classify(base.clone(), &[finding("run", 1, 21, 20)]),
842            [false]
843        );
844        // A new one added before both.
845        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        // The first one worsened, the second one unchanged.
857        assert_eq!(
858            classify(
859                base.clone(),
860                &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
861            ),
862            [false, false]
863        );
864        // The first one moved to a higher tier, the second one unchanged.
865        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        // Only cyclomatic is exceeded; cognitive grows below the limit.
909        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        // Cyclomatic 100 to 103 stays in the critical tier.
920        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        // Growth only in a metric that the finding does not exceed.
928        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        // The base finding exceeded only cyclomatic. A threshold override in
935        // the same change lowers the cognitive limit, so the head finding
936        // exceeds cognitive too, without cognitive growth.
937        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        // Cognitive grew and the base did not exceed it: newly exceeded.
944        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        // The same values from estimated coverage do not cross a tier.
957        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        // Two same-valued findings; the second one grew in lines.
1020        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        // The first head finding takes the 40-line base finding; the second one
1030        // takes the 10-line base finding and grew.
1031        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        // The 10-line function moved up and grew to 30 lines. The 40-line
1038        // function did not change, but it now comes later in line order.
1039        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        // A comment line or a formatter rewrap adds a few physical lines.
1060        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        // 4 lines are below the floor, also on a short function.
1071        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        // The floor does not scale: 9 lines on a 300-line function count.
1082        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}