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 no base finding matches it, or when a metric that the head finding
8//! exceeds has a higher value than in its matched base finding. Unchanged and
9//! decreased metrics stay inherited, also when the exceeded category changes
10//! (#3277).
11
12use std::path::Path;
13
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use crate::audit_keys::{health_finding_key, remap_key_for_renames};
17
18/// Metric values of one base complexity finding.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct ComplexityMetrics {
21    /// 1-based line of the function. Only orders same-named findings.
22    pub line: u32,
23    /// Cyclomatic complexity.
24    pub cyclomatic: u16,
25    /// Cognitive complexity.
26    pub cognitive: u16,
27    /// CRAP score, when coverage data exists.
28    pub crap: Option<f64>,
29}
30
31impl ComplexityMetrics {
32    /// The metric values of one finding.
33    #[must_use]
34    pub const fn of(finding: &fallow_output::ComplexityViolation) -> Self {
35        Self {
36            line: finding.line,
37            cyclomatic: finding.cyclomatic,
38            cognitive: finding.cognitive,
39            crap: finding.crap,
40        }
41    }
42
43    /// Whether both findings have the same metric values. The line is not
44    /// compared.
45    fn same_values(&self, other: &Self) -> bool {
46        self.cyclomatic == other.cyclomatic
47            && self.cognitive == other.cognitive
48            && self.crap.map(f64::to_bits) == other.crap.map(f64::to_bits)
49    }
50}
51
52/// Base complexity findings by identity key (`complexity:<path>:<function>`),
53/// each list sorted by line.
54pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
55
56/// The base complexity findings of a health report, by identity key.
57#[must_use]
58pub fn complexity_baseline(
59    report: &fallow_output::HealthReport,
60    root: &Path,
61) -> ComplexityBaseline {
62    let mut baseline = ComplexityBaseline::default();
63    for finding in &report.findings {
64        baseline
65            .entry(health_finding_key(finding, root))
66            .or_default()
67            .push(ComplexityMetrics::of(finding));
68    }
69    sort_by_line(&mut baseline);
70    baseline
71}
72
73/// The identity keys of a complexity baseline.
74#[must_use]
75pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
76    baseline.keys().cloned().collect()
77}
78
79/// Relocate the base findings of renamed files onto their head paths.
80///
81/// `renames` maps base-relative old paths to head-relative new paths, as in
82/// [`crate::audit_keys::remap_keys_for_renames`]. When two keys merge, their findings join
83/// one list, sorted by line.
84#[must_use]
85#[expect(
86    clippy::implicit_hasher,
87    reason = "fallow standardizes on FxHashMap across audit attribution keys"
88)]
89pub fn remap_complexity_baseline_for_renames(
90    baseline: ComplexityBaseline,
91    renames: &FxHashMap<String, String>,
92) -> ComplexityBaseline {
93    let mut remapped = ComplexityBaseline::default();
94    for (key, findings) in baseline {
95        remapped
96            .entry(remap_key_for_renames(&key, renames))
97            .or_default()
98            .extend(findings);
99    }
100    sort_by_line(&mut remapped);
101    remapped
102}
103
104fn sort_by_line(baseline: &mut ComplexityBaseline) {
105    for findings in baseline.values_mut() {
106        findings.sort_by_key(|metrics| metrics.line);
107    }
108}
109
110/// Whether `head` is worse than `base` in a metric that `head` exceeds.
111///
112/// A CRAP score that the base finding does not have counts as an increase.
113fn exceeded_metric_increased(
114    head: &fallow_output::ComplexityViolation,
115    base: &ComplexityMetrics,
116) -> bool {
117    let exceeded = head.exceeded;
118    (exceeded.includes_cyclomatic() && head.cyclomatic > base.cyclomatic)
119        || (exceeded.includes_cognitive() && head.cognitive > base.cognitive)
120        || (exceeded.includes_crap()
121            && match (head.crap, base.crap) {
122                (Some(head), Some(base)) => head > base,
123                (Some(_), None) => true,
124                (None, _) => false,
125            })
126}
127
128/// Classify each head complexity finding as introduced (`true`) or inherited
129/// (`false`), in report order.
130///
131/// Head and base findings match by identity key. When one key holds several
132/// findings on one side (same-named class methods or anonymous functions in
133/// one file), the match is ambiguous. This conservative rule then applies, per
134/// key:
135///
136/// 1. A head finding with the same metric values as an unmatched base finding
137///    takes that base finding and is inherited. This keeps unchanged findings
138///    inherited when a same-named finding is added, removed or edited.
139/// 2. The remaining head findings, in line order, take the remaining base
140///    findings, in line order. Each pair compares the exceeded metrics.
141/// 3. A head finding without a base finding is introduced.
142///
143/// Every base finding matches at most one head finding, so a new same-named
144/// finding cannot hide behind an existing one. The rule does not identify
145/// functions beyond path and name (see #2010). Two same-named findings that
146/// swap their metric values in one change stay inherited.
147#[must_use]
148pub fn classify_complexity_findings(
149    findings: &[fallow_output::HealthFinding],
150    root: &Path,
151    base: &ComplexityBaseline,
152) -> Vec<bool> {
153    let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
154    for (index, finding) in findings.iter().enumerate() {
155        groups
156            .entry(health_finding_key(finding, root))
157            .or_default()
158            .push(index);
159    }
160    let mut introduced = vec![true; findings.len()];
161    for (key, mut heads) in groups {
162        let Some(bases) = base.get(&key) else {
163            continue;
164        };
165        heads.sort_by_key(|&index| (findings[index].line, findings[index].col));
166        let mut unmatched_bases: Vec<Option<&ComplexityMetrics>> = bases.iter().map(Some).collect();
167        let mut unmatched_heads = Vec::new();
168        for index in heads {
169            let head = ComplexityMetrics::of(&findings[index]);
170            let same = unmatched_bases
171                .iter_mut()
172                .find(|slot| slot.is_some_and(|base| base.same_values(&head)));
173            match same {
174                Some(slot) => {
175                    *slot = None;
176                    introduced[index] = false;
177                }
178                None => unmatched_heads.push(index),
179            }
180        }
181        let mut remaining_bases = unmatched_bases.into_iter().flatten();
182        for index in unmatched_heads {
183            if let Some(base) = remaining_bases.next() {
184                introduced[index] = exceeded_metric_increased(&findings[index], base);
185            }
186        }
187    }
188    introduced
189}
190
191/// Classify each head complexity finding against several bases, in report
192/// order. A finding is introduced only when it is introduced against every
193/// base, so a finding that one base already has is inherited.
194#[must_use]
195pub fn classify_complexity_findings_against_all<'a>(
196    findings: &[fallow_output::HealthFinding],
197    root: &Path,
198    bases: impl IntoIterator<Item = &'a ComplexityBaseline>,
199) -> Vec<bool> {
200    let mut introduced = vec![true; findings.len()];
201    for base in bases {
202        for (flag, against) in introduced
203            .iter_mut()
204            .zip(classify_complexity_findings(findings, root, base))
205        {
206            *flag &= against;
207        }
208    }
209    introduced
210}
211
212/// The ledger key of each complexity finding, in report order.
213///
214/// A repeated identity key gets an occurrence suffix, so each finding counts
215/// once in the attribution counts. Without the suffix, same-named findings
216/// collapse to one count.
217#[must_use]
218pub fn complexity_ledger_keys(
219    findings: &[fallow_output::HealthFinding],
220    root: &Path,
221) -> Vec<String> {
222    let mut seen: FxHashMap<String, usize> = FxHashMap::default();
223    findings
224        .iter()
225        .map(|finding| {
226            let key = health_finding_key(finding, root);
227            let occurrence = seen.entry(key.clone()).or_default();
228            *occurrence += 1;
229            if *occurrence == 1 {
230                key
231            } else {
232                format!("{key}#{occurrence}")
233            }
234        })
235        .collect()
236}
237
238#[cfg(test)]
239mod tests {
240    use std::path::PathBuf;
241
242    use fallow_output::{
243        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
244    };
245
246    use super::*;
247
248    const MAX: u16 = 15;
249
250    fn root() -> PathBuf {
251        PathBuf::from("/project")
252    }
253
254    fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
255        HealthFinding::from(ComplexityViolation {
256            path: root().join("src/index.js"),
257            name: name.to_string(),
258            line,
259            col: 0,
260            cyclomatic,
261            cognitive,
262            line_count: 30,
263            param_count: 1,
264            react_hook_count: 0,
265            react_jsx_max_depth: 0,
266            react_prop_count: 0,
267            react_hook_profile: None,
268            exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
269            effective_severity: None,
270            severity: FindingSeverity::High,
271            crap: None,
272            mutation: None,
273            coverage_pct: None,
274            coverage_tier: None,
275            coverage_source: None,
276            inherited_from: None,
277            component_rollup: None,
278            contributions: Vec::new(),
279            effective_thresholds: None,
280            threshold_source: None,
281        })
282    }
283
284    fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
285        complexity_baseline(
286            &HealthReport {
287                findings,
288                ..HealthReport::default()
289            },
290            &root(),
291        )
292    }
293
294    fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
295        classify_complexity_findings(head, &root(), &baseline(base))
296    }
297
298    #[test]
299    fn identity_key_excludes_category_and_line() {
300        let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
301        assert_eq!(key, "complexity:src/index.js:hotspot");
302    }
303
304    #[test]
305    fn issue_cases() {
306        // Unchanged.
307        assert_eq!(
308            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
309            [false]
310        );
311        // Increased.
312        assert_eq!(
313            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
314            [true]
315        );
316        // Decreased, same category.
317        assert_eq!(
318            classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
319            [false]
320        );
321        // Decreased, category change from both to cyclomatic.
322        assert_eq!(
323            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
324            [false]
325        );
326        // New threshold crossing.
327        assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
328    }
329
330    #[test]
331    fn only_exceeded_metrics_can_introduce() {
332        // Cognitive grows but stays under the limit: only cyclomatic is
333        // exceeded, and it did not grow.
334        assert_eq!(
335            classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
336            [false]
337        );
338        // A category change to a newly exceeded metric that grew.
339        assert_eq!(
340            classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
341            [true]
342        );
343    }
344
345    #[test]
346    fn a_finding_that_any_base_has_is_inherited() {
347        let primary = baseline(vec![]);
348        let merged = baseline(vec![finding("f", 1, 17, 16)]);
349        let head = [finding("f", 1, 17, 16), finding("g", 9, 17, 16)];
350        assert_eq!(
351            classify_complexity_findings_against_all(&head, &root(), [&primary, &merged]),
352            [false, true]
353        );
354        // A finding that grew against every base stays introduced.
355        assert_eq!(
356            classify_complexity_findings_against_all(
357                &[finding("f", 1, 18, 17)],
358                &root(),
359                [&baseline(vec![finding("f", 1, 17, 16)]), &merged]
360            ),
361            [true]
362        );
363    }
364
365    #[test]
366    fn line_shift_keeps_the_match() {
367        assert_eq!(
368            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
369            [false]
370        );
371    }
372
373    #[test]
374    fn crap_without_base_value_counts_as_increase() {
375        let mut head = finding("f", 1, 16, 5);
376        head.violation.exceeded = ExceededThreshold::Crap;
377        head.violation.crap = Some(50.0);
378        let mut base = finding("f", 1, 16, 5);
379        base.violation.exceeded = ExceededThreshold::Crap;
380        assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
381        base.violation.crap = Some(60.0);
382        assert_eq!(classify(vec![base], &[head]), [false]);
383    }
384
385    #[test]
386    fn same_named_findings_match_unchanged_values_first() {
387        let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
388        // Unchanged.
389        assert_eq!(
390            classify(
391                base.clone(),
392                &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
393            ),
394            [false, false]
395        );
396        // First one removed: the second one keeps its own base finding.
397        assert_eq!(
398            classify(base.clone(), &[finding("run", 1, 21, 20)]),
399            [false]
400        );
401        // A new one added before both.
402        assert_eq!(
403            classify(
404                base.clone(),
405                &[
406                    finding("run", 1, 30, 30),
407                    finding("run", 20, 17, 16),
408                    finding("run", 50, 21, 20)
409                ]
410            ),
411            [true, false, false]
412        );
413        // The first one worsened, the second one unchanged.
414        assert_eq!(
415            classify(
416                base,
417                &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
418            ),
419            [true, false]
420        );
421    }
422
423    #[test]
424    fn rename_moves_the_base_findings() {
425        let base = baseline(vec![finding("f", 1, 17, 16)]);
426        let renames =
427            FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
428        let remapped = remap_complexity_baseline_for_renames(base, &renames);
429        assert!(remapped.contains_key("complexity:src/moved.js:f"));
430        assert!(!remapped.contains_key("complexity:src/index.js:f"));
431    }
432
433    #[test]
434    fn ledger_keys_count_same_named_findings_separately() {
435        let keys = complexity_ledger_keys(
436            &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
437            &root(),
438        );
439        assert_eq!(
440            keys,
441            [
442                "complexity:src/index.js:run",
443                "complexity:src/index.js:run#2"
444            ]
445        );
446    }
447}