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/// The ledger key of each complexity finding, in report order.
192///
193/// A repeated identity key gets an occurrence suffix, so each finding counts
194/// once in the attribution counts. Without the suffix, same-named findings
195/// collapse to one count.
196#[must_use]
197pub fn complexity_ledger_keys(
198    findings: &[fallow_output::HealthFinding],
199    root: &Path,
200) -> Vec<String> {
201    let mut seen: FxHashMap<String, usize> = FxHashMap::default();
202    findings
203        .iter()
204        .map(|finding| {
205            let key = health_finding_key(finding, root);
206            let occurrence = seen.entry(key.clone()).or_default();
207            *occurrence += 1;
208            if *occurrence == 1 {
209                key
210            } else {
211                format!("{key}#{occurrence}")
212            }
213        })
214        .collect()
215}
216
217#[cfg(test)]
218mod tests {
219    use std::path::PathBuf;
220
221    use fallow_output::{
222        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
223    };
224
225    use super::*;
226
227    const MAX: u16 = 15;
228
229    fn root() -> PathBuf {
230        PathBuf::from("/project")
231    }
232
233    fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
234        HealthFinding::from(ComplexityViolation {
235            path: root().join("src/index.js"),
236            name: name.to_string(),
237            line,
238            col: 0,
239            cyclomatic,
240            cognitive,
241            line_count: 30,
242            param_count: 1,
243            react_hook_count: 0,
244            react_jsx_max_depth: 0,
245            react_prop_count: 0,
246            react_hook_profile: None,
247            exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
248            effective_severity: None,
249            severity: FindingSeverity::High,
250            crap: None,
251            coverage_pct: None,
252            coverage_tier: None,
253            coverage_source: None,
254            inherited_from: None,
255            component_rollup: None,
256            contributions: Vec::new(),
257            effective_thresholds: None,
258            threshold_source: None,
259        })
260    }
261
262    fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
263        complexity_baseline(
264            &HealthReport {
265                findings,
266                ..HealthReport::default()
267            },
268            &root(),
269        )
270    }
271
272    fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
273        classify_complexity_findings(head, &root(), &baseline(base))
274    }
275
276    #[test]
277    fn identity_key_excludes_category_and_line() {
278        let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
279        assert_eq!(key, "complexity:src/index.js:hotspot");
280    }
281
282    #[test]
283    fn issue_cases() {
284        // Unchanged.
285        assert_eq!(
286            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
287            [false]
288        );
289        // Increased.
290        assert_eq!(
291            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
292            [true]
293        );
294        // Decreased, same category.
295        assert_eq!(
296            classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
297            [false]
298        );
299        // Decreased, category change from both to cyclomatic.
300        assert_eq!(
301            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
302            [false]
303        );
304        // New threshold crossing.
305        assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
306    }
307
308    #[test]
309    fn only_exceeded_metrics_can_introduce() {
310        // Cognitive grows but stays under the limit: only cyclomatic is
311        // exceeded, and it did not grow.
312        assert_eq!(
313            classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
314            [false]
315        );
316        // A category change to a newly exceeded metric that grew.
317        assert_eq!(
318            classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
319            [true]
320        );
321    }
322
323    #[test]
324    fn line_shift_keeps_the_match() {
325        assert_eq!(
326            classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
327            [false]
328        );
329    }
330
331    #[test]
332    fn crap_without_base_value_counts_as_increase() {
333        let mut head = finding("f", 1, 16, 5);
334        head.violation.exceeded = ExceededThreshold::Crap;
335        head.violation.crap = Some(50.0);
336        let mut base = finding("f", 1, 16, 5);
337        base.violation.exceeded = ExceededThreshold::Crap;
338        assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
339        base.violation.crap = Some(60.0);
340        assert_eq!(classify(vec![base], &[head]), [false]);
341    }
342
343    #[test]
344    fn same_named_findings_match_unchanged_values_first() {
345        let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
346        // Unchanged.
347        assert_eq!(
348            classify(
349                base.clone(),
350                &[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
351            ),
352            [false, false]
353        );
354        // First one removed: the second one keeps its own base finding.
355        assert_eq!(
356            classify(base.clone(), &[finding("run", 1, 21, 20)]),
357            [false]
358        );
359        // A new one added before both.
360        assert_eq!(
361            classify(
362                base.clone(),
363                &[
364                    finding("run", 1, 30, 30),
365                    finding("run", 20, 17, 16),
366                    finding("run", 50, 21, 20)
367                ]
368            ),
369            [true, false, false]
370        );
371        // The first one worsened, the second one unchanged.
372        assert_eq!(
373            classify(
374                base,
375                &[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
376            ),
377            [true, false]
378        );
379    }
380
381    #[test]
382    fn rename_moves_the_base_findings() {
383        let base = baseline(vec![finding("f", 1, 17, 16)]);
384        let renames =
385            FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
386        let remapped = remap_complexity_baseline_for_renames(base, &renames);
387        assert!(remapped.contains_key("complexity:src/moved.js:f"));
388        assert!(!remapped.contains_key("complexity:src/index.js:f"));
389    }
390
391    #[test]
392    fn ledger_keys_count_same_named_findings_separately() {
393        let keys = complexity_ledger_keys(
394            &[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
395            &root(),
396        );
397        assert_eq!(
398            keys,
399            [
400                "complexity:src/index.js:run",
401                "complexity:src/index.js:run#2"
402            ]
403        );
404    }
405}