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    /// Whether both findings have the same cyclomatic and cognitive values.
100    /// The CRAP score is not compared, because it also changes when only the
101    /// coverage input changes (#3815).
102    const fn same_structure(&self, other: &Self) -> bool {
103        self.cyclomatic == other.cyclomatic && self.cognitive == other.cognitive
104    }
105
106    /// Whether both findings have the same cyclomatic and cognitive values and
107    /// the same line count.
108    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/// Attribution of one head complexity finding against the base findings.
114#[derive(Debug, Clone, PartialEq, Default)]
115pub struct ComplexityClass {
116    /// `true` when no base finding matches, when a newly exceeded metric
117    /// grew, or when growth moved the finding to a higher severity tier.
118    pub introduced: bool,
119    /// The measured metrics that are higher than in the matched base finding.
120    /// Empty when no base finding matches or when no metric grew.
121    pub worsened_metrics: Vec<WorsenedMetric>,
122    /// `true` when a metric that the finding exceeds grew. Before this rule,
123    /// that growth made the finding introduced, so `audit.worsened: off`
124    /// keeps it worsened (see [`Self::clear_worsened_for_off`]).
125    pub exceeded_metric_grew: bool,
126}
127
128impl ComplexityClass {
129    /// `true` when a base finding matches, the finding is not introduced, and
130    /// a measured metric grew.
131    #[must_use]
132    pub fn is_worsened(&self) -> bool {
133        !self.introduced && !self.worsened_metrics.is_empty()
134    }
135
136    /// The audit-mode class of the finding.
137    #[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    /// Clear the grown metrics for `audit.worsened: off`. Growth only in
149    /// metrics that the finding does not exceed then stays inherited. Growth
150    /// of an exceeded metric stays worsened, so it gives at least `warn` and
151    /// does not disappear from the report.
152    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
181/// The measured metrics of `head` that are higher than in `base`.
182///
183/// The line count must grow by a minimum, see [`line_count_grew`]. CRAP
184/// compares only when both sides have a score, because a score that
185/// only one side has reflects the coverage input, not the code.
186fn 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
219/// The smallest line count growth, in lines, that makes a finding worsened.
220const LINE_COUNT_MIN_GROWTH_LINES: u32 = 5;
221
222/// Whether the line count grew by at least [`LINE_COUNT_MIN_GROWTH_LINES`]
223/// lines. The floor is fixed and does not scale with the function size, so
224/// growth in a large function is not hidden.
225///
226/// The line count is the physical span of the function, so comments and blank
227/// lines in the body count. The minimum keeps a comment or a formatter rewrap
228/// from making a finding worsened.
229fn line_count_grew(base: u32, head: u32) -> bool {
230    head.saturating_sub(base) >= LINE_COUNT_MIN_GROWTH_LINES
231}
232
233/// Base complexity findings by identity key (`complexity:<path>:<function>`),
234/// each list sorted by line.
235pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
236
237/// The base complexity findings of a health report, by identity key.
238#[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/// The identity keys of a complexity baseline.
255#[must_use]
256pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
257    baseline.keys().cloned().collect()
258}
259
260/// Relocate the base findings of renamed files onto their head paths.
261///
262/// `renames` maps base-relative old paths to head-relative new paths, as in
263/// [`crate::audit_keys::remap_keys_for_renames`]. When two keys merge, their findings join
264/// one list, sorted by line.
265#[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
291/// Whether `head` is worse than `base` in a metric that `head` exceeds.
292///
293/// CRAP counts only when both sides have a measured score (see
294/// [`measured_crap_grew`]).
295fn 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
305/// Whether `head` exceeds a metric that `base` did not exceed in its own run,
306/// and that metric grew. A metric that crosses its limit only because a
307/// threshold changed does not grow, so it stays inherited.
308fn 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
322/// Whether both sides have a CRAP score from measured coverage and the head
323/// score is higher. A score that only one side has, or a score from estimated
324/// coverage, reflects the coverage input, not the code.
325fn 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
334/// Whether the severity tier of `head` is higher than the tier of `base`.
335///
336/// The tiers use fixed limits, not the configured thresholds. The CRAP score
337/// takes part only when both sides have a measured score; else both tiers come
338/// from cyclomatic and cognitive complexity only.
339fn 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/// Whether the CRAP score of the finding comes from estimated coverage, not
360/// from a measured coverage map (`--coverage` or runtime coverage).
361#[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/// Whether CRAP from estimated coverage is the only threshold that the
370/// finding exceeds.
371#[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
376/// Give the gate severity `warn` to each finding that exceeds only CRAP from
377/// estimated coverage, for the `new-only` gate.
378///
379/// The estimate comes from static test reachability, so it does not measure
380/// the change. The finding stays in the report, and the verdict reads `warn`
381/// for it. A finding that also exceeds cyclomatic or cognitive complexity
382/// keeps its gate severity. Measured coverage keeps the gate severity too.
383pub 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
393/// Give the gate severity `warn` to each finding in a test file, and mark it
394/// with [`AuditGateExemption::TestFile`].
395///
396/// The shared test-path classifier ([`fallow_engine::test_paths::is_test_path`])
397/// decides what a test file is: specs, test directories, end-to-end suites,
398/// mocks and fixtures. The finding stays in the report. `fallow health` does
399/// not call this, so it keeps the rule severity there.
400pub 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/// Classify each head complexity finding as introduced (`true`) or inherited
411/// (`false`), in report order. See [`classify_complexity_attribution`].
412#[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/// Classify each head complexity finding against one base, in report order.
425///
426/// Head and base findings match by identity key. When one key holds several
427/// findings on one side (same-named class methods or anonymous functions in
428/// one file), the match is ambiguous. This conservative rule then applies, per
429/// key:
430///
431/// 1. Each head finding, in line order, takes an unmatched base finding with
432///    the same complexity values and the same line count.
433/// 2. Each remaining head finding, in line order, takes an unmatched base
434///    finding with the same complexity values. Rules 1 and 2 keep unchanged
435///    findings inherited when a same-named finding is added, removed or
436///    edited. Rule 1 runs for all head findings first, so an earlier head
437///    finding cannot take the exact match of a later one.
438/// 3. Each remaining head finding, in line order, takes an unmatched base
439///    finding with the same cyclomatic and cognitive values and the same line
440///    count, and then one with the same cyclomatic and cognitive values. The
441///    CRAP score is not compared in this rule, because a change of the
442///    coverage input changes the CRAP score of an unchanged function (#3815).
443///    The pair still compares the CRAP score, so CRAP growth stays visible.
444/// 4. The remaining head findings, in line order, take the remaining base
445///    findings, in line order. Each pair compares the exceeded metrics.
446/// 5. A head finding without a base finding is introduced.
447///
448/// Every base finding matches at most one head finding, so a new same-named
449/// finding cannot hide behind an existing one. The rule does not identify
450/// functions beyond path and name (see #2010). Two same-named findings that
451/// swap their metric values in one change stay inherited.
452///
453/// Each matched pair also lists the measured metrics that grew, so an
454/// inherited finding that got worse is visible.
455#[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/// Classify each head complexity finding against several bases, in report
510/// order. A finding is introduced only when it is introduced against every
511/// base, so a finding that one base already has is inherited.
512#[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/// Classify each head complexity finding against several bases, in report
525/// order.
526///
527/// A finding is introduced only when it is introduced against every base. A
528/// finding that is not introduced takes the smallest list of grown metrics of
529/// the bases that it is not introduced against, so a finding that one base
530/// already has with the same values is inherited, not worsened. An introduced
531/// finding keeps the grown metrics of the first base that matches it.
532#[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/// The ledger key of each complexity finding, in report order.
563///
564/// A repeated identity key gets an occurrence suffix, so each finding counts
565/// once in the attribution counts. Without the suffix, same-named findings
566/// collapse to one count.
567#[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        // Unchanged.
658        assert_eq!(
659            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
660            [false]
661        );
662        // Increased in the same severity tier: worsened, not introduced.
663        assert_eq!(
664            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
665            [false]
666        );
667        // Decreased, same category.
668        assert_eq!(
669            classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
670            [false]
671        );
672        // Decreased, category change from both to cyclomatic.
673        assert_eq!(
674            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
675            [false]
676        );
677        // New threshold crossing.
678        assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
679    }
680
681    #[test]
682    fn only_exceeded_metrics_can_introduce() {
683        // Cognitive grows but stays under the limit: only cyclomatic is
684        // exceeded, and it did not grow.
685        assert_eq!(
686            classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
687            [false]
688        );
689        // A category change to a newly exceeded metric that grew.
690        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        // A finding that moved to a higher tier against every base stays
706        // introduced.
707        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        // A base without a CRAP score ran without that coverage input. The
737        // code did not grow.
738        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        // Measured CRAP growth in the same tier is worsened.
744        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        // A grown cognitive value that the base already exceeds is worsened.
773        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        // A grown cognitive value that reaches the next tier is introduced.
781        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        // The project itself lives below a `tests` directory. Only the path
788        // below the project root decides.
789        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        // Unchanged.
854        assert_eq!(
855            classify(
856                base.clone(),
857                &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
858            ),
859            [false, false]
860        );
861        // First one removed: the second one keeps its own base finding.
862        assert_eq!(
863            classify(base.clone(), &[finding("run", 1, 21, 20)]),
864            [false]
865        );
866        // A new one added before both.
867        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        // The first one worsened, the second one unchanged.
879        assert_eq!(
880            classify(
881                base.clone(),
882                &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
883            ),
884            [false, false]
885        );
886        // The first one moved to a higher tier, the second one unchanged.
887        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    /// An anonymous finding with a CRAP score from the given coverage source.
897    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        // Issue #3815: the CRAP score of an unchanged arrow went from an
920        // estimated score to a measured score. Its cyclomatic and cognitive
921        // values did not change, so it must keep its own base finding, not the
922        // first remaining base finding in line order.
923        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        // Only cyclomatic is exceeded; cognitive grows below the limit.
1007        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        // Cyclomatic 100 to 103 stays in the critical tier.
1018        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        // Growth only in a metric that the finding does not exceed.
1026        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        // The base finding exceeded only cyclomatic. A threshold override in
1033        // the same change lowers the cognitive limit, so the head finding
1034        // exceeds cognitive too, without cognitive growth.
1035        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        // Cognitive grew and the base did not exceed it: newly exceeded.
1042        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        // The same values from estimated coverage do not cross a tier.
1055        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        // Two same-valued findings; the second one grew in lines.
1118        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        // The first head finding takes the 40-line base finding; the second one
1128        // takes the 10-line base finding and grew.
1129        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        // The 10-line function moved up and grew to 30 lines. The 40-line
1136        // function did not change, but it now comes later in line order.
1137        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        // A comment line or a formatter rewrap adds a few physical lines.
1158        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        // 4 lines are below the floor, also on a short function.
1169        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        // The floor does not scale: 9 lines on a 300-line function count.
1180        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}