Skip to main content

lean_ctx/core/code_health/
delta.rs

1//! Edit-time cognitive-complexity delta.
2//!
3//! Computes how an edit changes per-function cognitive complexity **without
4//! touching the index** — pure and deterministic, so it can run inside the
5//! edit path (`ctx_edit`, `ctx_patch`) and the native-edit hook to prevent
6//! complexity drift at the moment it is introduced. Functions are matched by
7//! name (their stable identity across an in-place edit).
8
9use super::cognitive::cognitive_per_function;
10use std::collections::BTreeMap;
11
12/// One function's cognitive-complexity change across an edit.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CognitiveDelta {
15    pub name: String,
16    pub before: u32,
17    pub after: u32,
18}
19
20impl CognitiveDelta {
21    /// Signed change (positive = got more complex).
22    pub fn increase(&self) -> i64 {
23        i64::from(self.after) - i64::from(self.before)
24    }
25
26    /// True when the edit pushed a previously-acceptable function over
27    /// `threshold` (the case `gate="block"` refuses).
28    pub fn crosses_threshold(&self, threshold: u32) -> bool {
29        self.before <= threshold && self.after > threshold
30    }
31}
32
33/// Compare cognitive complexity of every function between `old` and `new`
34/// source for file `ext`. Returns only functions whose complexity changed,
35/// sorted by name. Empty when tree-sitter is disabled or nothing changed.
36///
37/// Only functions present in `new` are reported (a deleted function is not a
38/// regression the editor needs to hear about).
39pub fn cognitive_delta(old: &str, new: &str, ext: &str) -> Vec<CognitiveDelta> {
40    let before = map_by_name(old, ext);
41    let after = map_by_name(new, ext);
42
43    let mut out: Vec<CognitiveDelta> = after
44        .iter()
45        .filter_map(|(name, &after_cc)| {
46            let before_cc = before.get(name).copied().unwrap_or(0);
47            (before_cc != after_cc).then(|| CognitiveDelta {
48                name: name.clone(),
49                before: before_cc,
50                after: after_cc,
51            })
52        })
53        .collect();
54    out.sort_by(|a, b| a.name.cmp(&b.name));
55    out
56}
57
58/// The most significant increase that ends above `threshold`, if any. This is
59/// what the edit-gate surfaces (one line, the worst offender).
60pub fn worst_regression(deltas: &[CognitiveDelta], threshold: u32) -> Option<&CognitiveDelta> {
61    deltas
62        .iter()
63        .filter(|d| d.after > d.before && d.after > threshold)
64        .max_by_key(|d| {
65            (
66                d.after - d.before,
67                d.after,
68                std::cmp::Reverse(d.name.clone()),
69            )
70        })
71}
72
73/// Deterministic one-line edit-gate notice. No timestamps/counters → #498-safe.
74pub fn format_gate_notice(delta: &CognitiveDelta, threshold: u32) -> String {
75    format!(
76        "[CODE HEALTH] fn {}: cognitive {}->{} (+{}, >{}) — consider extracting helpers",
77        delta.name,
78        delta.before,
79        delta.after,
80        delta.after.saturating_sub(delta.before),
81        threshold
82    )
83}
84
85fn map_by_name(source: &str, ext: &str) -> BTreeMap<String, u32> {
86    let mut map: BTreeMap<String, u32> = BTreeMap::new();
87    if let Some(fns) = cognitive_per_function(source, ext) {
88        for f in fns {
89            let entry = map.entry(f.name).or_insert(0);
90            *entry = (*entry).max(f.cognitive);
91        }
92    }
93    map
94}
95
96#[cfg(all(test, feature = "tree-sitter"))]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn reports_increase_for_edited_function() {
102        let old = "fn f(a: bool) { if a {} }";
103        let new = "fn f(a: bool, b: bool) { if a { if b {} } }";
104        let deltas = cognitive_delta(old, new, "rs");
105        assert_eq!(deltas.len(), 1);
106        assert_eq!(deltas[0].name, "f");
107        assert_eq!(deltas[0].before, 1);
108        assert_eq!(deltas[0].after, 3);
109        assert_eq!(deltas[0].increase(), 2);
110    }
111
112    #[test]
113    fn ignores_unchanged_functions() {
114        let src = "fn stable(a: bool) { if a {} }";
115        assert!(cognitive_delta(src, src, "rs").is_empty());
116    }
117
118    #[test]
119    fn new_function_starts_from_zero() {
120        let old = "fn a() {}";
121        let new = "fn a() {}\nfn b(x: bool) { if x { if x {} } }";
122        let deltas = cognitive_delta(old, new, "rs");
123        let b = deltas.iter().find(|d| d.name == "b").unwrap();
124        assert_eq!(b.before, 0);
125        assert_eq!(b.after, 3);
126    }
127
128    #[test]
129    fn worst_regression_picks_threshold_crosser() {
130        let deltas = vec![
131            CognitiveDelta {
132                name: "small".into(),
133                before: 2,
134                after: 5,
135            },
136            CognitiveDelta {
137                name: "big".into(),
138                before: 10,
139                after: 20,
140            },
141        ];
142        let worst = worst_regression(&deltas, 15).unwrap();
143        assert_eq!(worst.name, "big");
144        assert!(deltas[1].crosses_threshold(15));
145        assert!(!deltas[0].crosses_threshold(15));
146    }
147
148    #[test]
149    fn notice_is_deterministic() {
150        let d = CognitiveDelta {
151            name: "foo".into(),
152            before: 8,
153            after: 16,
154        };
155        let n1 = format_gate_notice(&d, 15);
156        let n2 = format_gate_notice(&d, 15);
157        assert_eq!(n1, n2);
158        assert!(n1.contains("cognitive 8->16 (+8, >15)"));
159    }
160}