Skip to main content

hotspots_core/
patterns.rs

1//! Pattern classification engine
2//!
3//! Pure, stateless pattern detection from function metrics.
4//! No I/O. Same inputs always produce the same outputs.
5//! See `docs/patterns.md` for the canonical specification.
6
7use serde::{Deserialize, Serialize};
8
9/// Input for Tier 1 (structural) pattern classification.
10/// Available in all analysis modes from raw metrics.
11pub struct Tier1Input {
12    pub cc: usize,
13    pub nd: usize,
14    pub fo: usize,
15    pub ns: usize,
16    pub loc: usize,
17}
18
19/// Input for Tier 2 (enriched) pattern classification.
20/// All fields are `Option` — absent outside snapshot mode.
21pub struct Tier2Input {
22    pub fan_in: Option<usize>,
23    pub scc_size: Option<usize>,
24    pub churn_lines: Option<usize>,
25    pub days_since_last_change: Option<u32>,
26    pub neighbor_churn: Option<usize>,
27    /// Suppresses `middle_man` and `neighbor_risk` when true.
28    /// Set from call graph entry point detection.
29    pub is_entrypoint: bool,
30}
31
32/// Default thresholds for all patterns. Values match `docs/patterns.md`.
33///
34/// Pass `&Thresholds::default()` unless the project has configured overrides
35/// via `.hotspotsrc.json`. The `classify` functions accept this by reference
36/// so the type signature accommodates overrides without any API change.
37#[derive(Debug, Clone)]
38pub struct Thresholds {
39    pub complex_branching_cc: usize,
40    pub complex_branching_nd: usize,
41    pub deeply_nested_nd: usize,
42    pub exit_heavy_ns: usize,
43    pub god_function_loc: usize,
44    pub god_function_fo: usize,
45    pub long_function_loc: usize,
46    pub churn_magnet_churn: usize,
47    pub churn_magnet_cc: usize,
48    pub cyclic_hub_scc: usize,
49    pub cyclic_hub_fan_in: usize,
50    pub hub_function_fan_in: usize,
51    pub hub_function_cc: usize,
52    pub middle_man_fan_in: usize,
53    pub middle_man_fo: usize,
54    pub middle_man_cc_max: usize,
55    pub neighbor_risk_churn: usize,
56    pub neighbor_risk_fo: usize,
57    pub shotgun_target_fan_in: usize,
58    pub shotgun_target_churn: usize,
59    pub stale_complex_cc: usize,
60    pub stale_complex_loc: usize,
61    pub stale_complex_days: u32,
62}
63
64impl Default for Thresholds {
65    fn default() -> Self {
66        Thresholds {
67            complex_branching_cc: 10,
68            complex_branching_nd: 4,
69            deeply_nested_nd: 5,
70            exit_heavy_ns: 5,
71            god_function_loc: 60,
72            god_function_fo: 10,
73            long_function_loc: 80,
74            churn_magnet_churn: 200,
75            churn_magnet_cc: 8,
76            cyclic_hub_scc: 2,
77            cyclic_hub_fan_in: 6,
78            hub_function_fan_in: 10,
79            hub_function_cc: 8,
80            middle_man_fan_in: 8,
81            middle_man_fo: 8,
82            middle_man_cc_max: 4,
83            neighbor_risk_churn: 400,
84            neighbor_risk_fo: 8,
85            shotgun_target_fan_in: 8,
86            shotgun_target_churn: 150,
87            stale_complex_cc: 10,
88            stale_complex_loc: 60,
89            stale_complex_days: 180,
90        }
91    }
92}
93
94/// A single metric condition that caused a pattern to fire.
95/// Populated only when `--explain-patterns` is requested.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct TriggeredBy {
98    pub metric: String,
99    pub op: String,
100    pub value: usize,
101    pub threshold: usize,
102}
103
104/// Full detail for a single fired pattern.
105/// Populated only when `--explain-patterns` is requested.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct PatternDetail {
108    pub id: String,
109    /// 1 = Tier 1 (structural), 2 = Tier 2 (enriched).
110    pub tier: u8,
111    /// "primitive" or "derived".
112    pub kind: String,
113    pub triggered_by: Vec<TriggeredBy>,
114}
115
116/// Classify patterns and return sorted IDs.
117///
118/// Ordering: Tier 1 patterns alphabetically, then Tier 2 alphabetically.
119/// Delegates entirely to `classify_detailed` — no separate threshold logic.
120pub fn classify(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Vec<String> {
121    classify_detailed(t1, t2, th)
122        .into_iter()
123        .map(|d| d.id)
124        .collect()
125}
126
127/// Classify patterns and return full detail for each.
128///
129/// This is the canonical implementation. `classify` delegates here.
130/// Use this when `--explain-patterns` is active.
131pub fn classify_detailed(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Vec<PatternDetail> {
132    let mut results = Vec::new();
133
134    // Compute primitives needed for derived pattern check up-front.
135    let god = check_god_function(t1, th);
136    let churn = check_churn_magnet(t1, t2, th);
137
138    // Tier 1 — alphabetical
139    if let Some(d) = check_complex_branching(t1, th) {
140        results.push(d);
141    }
142    if let Some(d) = check_deeply_nested(t1, th) {
143        results.push(d);
144    }
145    if let Some(d) = check_exit_heavy(t1, th) {
146        results.push(d);
147    }
148    if let Some(d) = god.clone() {
149        results.push(d);
150    }
151    if let Some(d) = check_long_function(t1, th) {
152        results.push(d);
153    }
154
155    // Tier 2 — alphabetical
156    if let Some(d) = churn.clone() {
157        results.push(d);
158    }
159    if let Some(d) = check_cyclic_hub(t2, th) {
160        results.push(d);
161    }
162    if let Some(d) = check_hub_function(t1, t2, th) {
163        results.push(d);
164    }
165    if let Some(d) = check_middle_man(t1, t2, th) {
166        results.push(d);
167    }
168    if let Some(d) = check_neighbor_risk(t1, t2, th) {
169        results.push(d);
170    }
171    if let Some(d) = check_shotgun_target(t2, th) {
172        results.push(d);
173    }
174    if let Some(d) = check_stale_complex(t1, t2, th) {
175        results.push(d);
176    }
177
178    // volatile_god: derived — fires iff both god_function AND churn_magnet fired.
179    // triggered_by is the union of both; no raw thresholds re-evaluated here.
180    if let (Some(ref g), Some(ref c)) = (&god, &churn) {
181        let mut triggered_by = g.triggered_by.clone();
182        triggered_by.extend(c.triggered_by.clone());
183        results.push(PatternDetail {
184            id: "volatile_god".to_string(),
185            tier: 2,
186            kind: "derived".to_string(),
187            triggered_by,
188        });
189    }
190
191    results
192}
193
194// ---------- Tier 1 helpers ----------
195
196fn check_complex_branching(t: &Tier1Input, th: &Thresholds) -> Option<PatternDetail> {
197    if t.cc >= th.complex_branching_cc && t.nd >= th.complex_branching_nd {
198        Some(PatternDetail {
199            id: "complex_branching".to_string(),
200            tier: 1,
201            kind: "primitive".to_string(),
202            triggered_by: vec![
203                tb("CC", ">=", t.cc, th.complex_branching_cc),
204                tb("ND", ">=", t.nd, th.complex_branching_nd),
205            ],
206        })
207    } else {
208        None
209    }
210}
211
212fn check_deeply_nested(t: &Tier1Input, th: &Thresholds) -> Option<PatternDetail> {
213    if t.nd >= th.deeply_nested_nd {
214        Some(PatternDetail {
215            id: "deeply_nested".to_string(),
216            tier: 1,
217            kind: "primitive".to_string(),
218            triggered_by: vec![tb("ND", ">=", t.nd, th.deeply_nested_nd)],
219        })
220    } else {
221        None
222    }
223}
224
225fn check_exit_heavy(t: &Tier1Input, th: &Thresholds) -> Option<PatternDetail> {
226    if t.ns >= th.exit_heavy_ns {
227        Some(PatternDetail {
228            id: "exit_heavy".to_string(),
229            tier: 1,
230            kind: "primitive".to_string(),
231            triggered_by: vec![tb("NS", ">=", t.ns, th.exit_heavy_ns)],
232        })
233    } else {
234        None
235    }
236}
237
238fn check_god_function(t: &Tier1Input, th: &Thresholds) -> Option<PatternDetail> {
239    if t.loc >= th.god_function_loc && t.fo >= th.god_function_fo {
240        Some(PatternDetail {
241            id: "god_function".to_string(),
242            tier: 1,
243            kind: "primitive".to_string(),
244            triggered_by: vec![
245                tb("LOC", ">=", t.loc, th.god_function_loc),
246                tb("FO", ">=", t.fo, th.god_function_fo),
247            ],
248        })
249    } else {
250        None
251    }
252}
253
254fn check_long_function(t: &Tier1Input, th: &Thresholds) -> Option<PatternDetail> {
255    if t.loc >= th.long_function_loc {
256        Some(PatternDetail {
257            id: "long_function".to_string(),
258            tier: 1,
259            kind: "primitive".to_string(),
260            triggered_by: vec![tb("LOC", ">=", t.loc, th.long_function_loc)],
261        })
262    } else {
263        None
264    }
265}
266
267// ---------- Tier 2 helpers ----------
268
269fn check_churn_magnet(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
270    let churn = t2.churn_lines?;
271    if churn >= th.churn_magnet_churn && t1.cc >= th.churn_magnet_cc {
272        Some(PatternDetail {
273            id: "churn_magnet".to_string(),
274            tier: 2,
275            kind: "primitive".to_string(),
276            triggered_by: vec![
277                tb("churn_lines", ">=", churn, th.churn_magnet_churn),
278                tb("CC", ">=", t1.cc, th.churn_magnet_cc),
279            ],
280        })
281    } else {
282        None
283    }
284}
285
286fn check_cyclic_hub(t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
287    let scc = t2.scc_size?;
288    let fan_in = t2.fan_in?;
289    if scc >= th.cyclic_hub_scc && fan_in >= th.cyclic_hub_fan_in {
290        Some(PatternDetail {
291            id: "cyclic_hub".to_string(),
292            tier: 2,
293            kind: "primitive".to_string(),
294            triggered_by: vec![
295                tb("scc_size", ">=", scc, th.cyclic_hub_scc),
296                tb("fan_in", ">=", fan_in, th.cyclic_hub_fan_in),
297            ],
298        })
299    } else {
300        None
301    }
302}
303
304fn check_hub_function(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
305    let fan_in = t2.fan_in?;
306    if fan_in >= th.hub_function_fan_in && t1.cc >= th.hub_function_cc {
307        Some(PatternDetail {
308            id: "hub_function".to_string(),
309            tier: 2,
310            kind: "primitive".to_string(),
311            triggered_by: vec![
312                tb("fan_in", ">=", fan_in, th.hub_function_fan_in),
313                tb("CC", ">=", t1.cc, th.hub_function_cc),
314            ],
315        })
316    } else {
317        None
318    }
319}
320
321fn check_middle_man(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
322    if t2.is_entrypoint {
323        return None;
324    }
325    let fan_in = t2.fan_in?;
326    if fan_in >= th.middle_man_fan_in && t1.fo >= th.middle_man_fo && t1.cc <= th.middle_man_cc_max
327    {
328        Some(PatternDetail {
329            id: "middle_man".to_string(),
330            tier: 2,
331            kind: "primitive".to_string(),
332            triggered_by: vec![
333                tb("fan_in", ">=", fan_in, th.middle_man_fan_in),
334                tb("FO", ">=", t1.fo, th.middle_man_fo),
335                tb("CC", "<=", t1.cc, th.middle_man_cc_max),
336            ],
337        })
338    } else {
339        None
340    }
341}
342
343fn check_neighbor_risk(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
344    if t2.is_entrypoint {
345        return None;
346    }
347    let nc = t2.neighbor_churn?;
348    if nc >= th.neighbor_risk_churn && t1.fo >= th.neighbor_risk_fo {
349        Some(PatternDetail {
350            id: "neighbor_risk".to_string(),
351            tier: 2,
352            kind: "primitive".to_string(),
353            triggered_by: vec![
354                tb("neighbor_churn", ">=", nc, th.neighbor_risk_churn),
355                tb("FO", ">=", t1.fo, th.neighbor_risk_fo),
356            ],
357        })
358    } else {
359        None
360    }
361}
362
363fn check_shotgun_target(t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
364    let fan_in = t2.fan_in?;
365    let churn = t2.churn_lines?;
366    if fan_in >= th.shotgun_target_fan_in && churn >= th.shotgun_target_churn {
367        Some(PatternDetail {
368            id: "shotgun_target".to_string(),
369            tier: 2,
370            kind: "primitive".to_string(),
371            triggered_by: vec![
372                tb("fan_in", ">=", fan_in, th.shotgun_target_fan_in),
373                tb("churn_lines", ">=", churn, th.shotgun_target_churn),
374            ],
375        })
376    } else {
377        None
378    }
379}
380
381fn check_stale_complex(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Option<PatternDetail> {
382    let days = t2.days_since_last_change?;
383    if t1.cc >= th.stale_complex_cc
384        && t1.loc >= th.stale_complex_loc
385        && days >= th.stale_complex_days
386    {
387        Some(PatternDetail {
388            id: "stale_complex".to_string(),
389            tier: 2,
390            kind: "primitive".to_string(),
391            triggered_by: vec![
392                tb("CC", ">=", t1.cc, th.stale_complex_cc),
393                tb("LOC", ">=", t1.loc, th.stale_complex_loc),
394                tb(
395                    "days_since_last_change",
396                    ">=",
397                    days as usize,
398                    th.stale_complex_days as usize,
399                ),
400            ],
401        })
402    } else {
403        None
404    }
405}
406
407/// Helper: construct a `TriggeredBy` record.
408fn tb(metric: &str, op: &str, value: usize, threshold: usize) -> TriggeredBy {
409    TriggeredBy {
410        metric: metric.to_string(),
411        op: op.to_string(),
412        value,
413        threshold,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn t1(cc: usize, nd: usize, fo: usize, ns: usize, loc: usize) -> Tier1Input {
422        Tier1Input {
423            cc,
424            nd,
425            fo,
426            ns,
427            loc,
428        }
429    }
430
431    fn t2_none() -> Tier2Input {
432        Tier2Input {
433            fan_in: None,
434            scc_size: None,
435            churn_lines: None,
436            days_since_last_change: None,
437            neighbor_churn: None,
438            is_entrypoint: false,
439        }
440    }
441
442    fn t2(
443        fan_in: usize,
444        scc_size: usize,
445        churn_lines: usize,
446        days: u32,
447        neighbor_churn: usize,
448    ) -> Tier2Input {
449        Tier2Input {
450            fan_in: Some(fan_in),
451            scc_size: Some(scc_size),
452            churn_lines: Some(churn_lines),
453            days_since_last_change: Some(days),
454            neighbor_churn: Some(neighbor_churn),
455            is_entrypoint: false,
456        }
457    }
458
459    fn has(patterns: &[String], id: &str) -> bool {
460        patterns.iter().any(|p| p == id)
461    }
462
463    fn th() -> Thresholds {
464        Thresholds::default()
465    }
466
467    // ---------- complex_branching ----------
468
469    #[test]
470    fn complex_branching_below_threshold() {
471        let p = classify(&t1(9, 4, 0, 0, 0), &t2_none(), &th());
472        assert!(!has(&p, "complex_branching"));
473        let p = classify(&t1(10, 3, 0, 0, 0), &t2_none(), &th());
474        assert!(!has(&p, "complex_branching"));
475    }
476
477    #[test]
478    fn complex_branching_at_threshold() {
479        let p = classify(&t1(10, 4, 0, 0, 0), &t2_none(), &th());
480        assert!(has(&p, "complex_branching"));
481    }
482
483    #[test]
484    fn complex_branching_above_threshold() {
485        let p = classify(&t1(20, 8, 0, 0, 0), &t2_none(), &th());
486        assert!(has(&p, "complex_branching"));
487    }
488
489    // ---------- deeply_nested ----------
490
491    #[test]
492    fn deeply_nested_below_threshold() {
493        let p = classify(&t1(0, 4, 0, 0, 0), &t2_none(), &th());
494        assert!(!has(&p, "deeply_nested"));
495    }
496
497    #[test]
498    fn deeply_nested_at_threshold() {
499        let p = classify(&t1(0, 5, 0, 0, 0), &t2_none(), &th());
500        assert!(has(&p, "deeply_nested"));
501    }
502
503    #[test]
504    fn deeply_nested_above_threshold() {
505        let p = classify(&t1(0, 10, 0, 0, 0), &t2_none(), &th());
506        assert!(has(&p, "deeply_nested"));
507    }
508
509    // ---------- exit_heavy ----------
510
511    #[test]
512    fn exit_heavy_below_threshold() {
513        let p = classify(&t1(0, 0, 0, 4, 0), &t2_none(), &th());
514        assert!(!has(&p, "exit_heavy"));
515    }
516
517    #[test]
518    fn exit_heavy_at_threshold() {
519        let p = classify(&t1(0, 0, 0, 5, 0), &t2_none(), &th());
520        assert!(has(&p, "exit_heavy"));
521    }
522
523    #[test]
524    fn exit_heavy_above_threshold() {
525        let p = classify(&t1(0, 0, 0, 10, 0), &t2_none(), &th());
526        assert!(has(&p, "exit_heavy"));
527    }
528
529    // ---------- god_function ----------
530
531    #[test]
532    fn god_function_below_threshold() {
533        // LOC below
534        let p = classify(&t1(0, 0, 10, 0, 59), &t2_none(), &th());
535        assert!(!has(&p, "god_function"));
536        // FO below
537        let p = classify(&t1(0, 0, 9, 0, 60), &t2_none(), &th());
538        assert!(!has(&p, "god_function"));
539    }
540
541    #[test]
542    fn god_function_at_threshold() {
543        let p = classify(&t1(0, 0, 10, 0, 60), &t2_none(), &th());
544        assert!(has(&p, "god_function"));
545    }
546
547    #[test]
548    fn god_function_above_threshold() {
549        let p = classify(&t1(0, 0, 20, 0, 120), &t2_none(), &th());
550        assert!(has(&p, "god_function"));
551    }
552
553    // ---------- long_function ----------
554
555    #[test]
556    fn long_function_below_threshold() {
557        let p = classify(&t1(0, 0, 0, 0, 79), &t2_none(), &th());
558        assert!(!has(&p, "long_function"));
559    }
560
561    #[test]
562    fn long_function_at_threshold() {
563        let p = classify(&t1(0, 0, 0, 0, 80), &t2_none(), &th());
564        assert!(has(&p, "long_function"));
565    }
566
567    #[test]
568    fn long_function_above_threshold() {
569        let p = classify(&t1(0, 0, 0, 0, 200), &t2_none(), &th());
570        assert!(has(&p, "long_function"));
571    }
572
573    // ---------- churn_magnet ----------
574
575    #[test]
576    fn churn_magnet_below_threshold() {
577        // churn below
578        let t = Tier2Input {
579            churn_lines: Some(199),
580            ..t2(0, 1, 0, 0, 0)
581        };
582        let p = classify(&t1(8, 0, 0, 0, 0), &t, &th());
583        assert!(!has(&p, "churn_magnet"));
584        // cc below
585        let t = Tier2Input {
586            churn_lines: Some(200),
587            ..t2(0, 1, 0, 0, 0)
588        };
589        let p = classify(&t1(7, 0, 0, 0, 0), &t, &th());
590        assert!(!has(&p, "churn_magnet"));
591    }
592
593    #[test]
594    fn churn_magnet_at_threshold() {
595        let t = Tier2Input {
596            churn_lines: Some(200),
597            ..t2(0, 1, 0, 0, 0)
598        };
599        let p = classify(&t1(8, 0, 0, 0, 0), &t, &th());
600        assert!(has(&p, "churn_magnet"));
601    }
602
603    #[test]
604    fn churn_magnet_above_threshold() {
605        let t = Tier2Input {
606            churn_lines: Some(500),
607            ..t2(0, 1, 0, 0, 0)
608        };
609        let p = classify(&t1(15, 0, 0, 0, 0), &t, &th());
610        assert!(has(&p, "churn_magnet"));
611    }
612
613    // ---------- cyclic_hub ----------
614
615    #[test]
616    fn cyclic_hub_below_threshold() {
617        // scc below
618        let t = Tier2Input {
619            scc_size: Some(1),
620            fan_in: Some(6),
621            ..t2_none()
622        };
623        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
624        assert!(!has(&p, "cyclic_hub"));
625        // fan_in below
626        let t = Tier2Input {
627            scc_size: Some(2),
628            fan_in: Some(5),
629            ..t2_none()
630        };
631        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
632        assert!(!has(&p, "cyclic_hub"));
633    }
634
635    #[test]
636    fn cyclic_hub_at_threshold() {
637        let t = Tier2Input {
638            scc_size: Some(2),
639            fan_in: Some(6),
640            ..t2_none()
641        };
642        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
643        assert!(has(&p, "cyclic_hub"));
644    }
645
646    #[test]
647    fn cyclic_hub_above_threshold() {
648        let t = Tier2Input {
649            scc_size: Some(5),
650            fan_in: Some(20),
651            ..t2_none()
652        };
653        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
654        assert!(has(&p, "cyclic_hub"));
655    }
656
657    // ---------- hub_function ----------
658
659    #[test]
660    fn hub_function_below_threshold() {
661        // fan_in below
662        let t = Tier2Input {
663            fan_in: Some(9),
664            ..t2_none()
665        };
666        let p = classify(&t1(8, 0, 0, 0, 0), &t, &th());
667        assert!(!has(&p, "hub_function"));
668        // cc below
669        let t = Tier2Input {
670            fan_in: Some(10),
671            ..t2_none()
672        };
673        let p = classify(&t1(7, 0, 0, 0, 0), &t, &th());
674        assert!(!has(&p, "hub_function"));
675    }
676
677    #[test]
678    fn hub_function_at_threshold() {
679        let t = Tier2Input {
680            fan_in: Some(10),
681            ..t2_none()
682        };
683        let p = classify(&t1(8, 0, 0, 0, 0), &t, &th());
684        assert!(has(&p, "hub_function"));
685    }
686
687    #[test]
688    fn hub_function_above_threshold() {
689        let t = Tier2Input {
690            fan_in: Some(25),
691            ..t2_none()
692        };
693        let p = classify(&t1(20, 0, 0, 0, 0), &t, &th());
694        assert!(has(&p, "hub_function"));
695    }
696
697    // ---------- middle_man ----------
698
699    #[test]
700    fn middle_man_below_threshold() {
701        // fan_in below
702        let t = Tier2Input {
703            fan_in: Some(7),
704            ..t2_none()
705        };
706        let p = classify(&t1(2, 0, 8, 0, 0), &t, &th());
707        assert!(!has(&p, "middle_man"));
708        // fo below
709        let t = Tier2Input {
710            fan_in: Some(8),
711            ..t2_none()
712        };
713        let p = classify(&t1(2, 0, 7, 0, 0), &t, &th());
714        assert!(!has(&p, "middle_man"));
715        // cc above max
716        let t = Tier2Input {
717            fan_in: Some(8),
718            ..t2_none()
719        };
720        let p = classify(&t1(5, 0, 8, 0, 0), &t, &th());
721        assert!(!has(&p, "middle_man"));
722    }
723
724    #[test]
725    fn middle_man_at_threshold() {
726        let t = Tier2Input {
727            fan_in: Some(8),
728            ..t2_none()
729        };
730        let p = classify(&t1(4, 0, 8, 0, 0), &t, &th());
731        assert!(has(&p, "middle_man"));
732    }
733
734    #[test]
735    fn middle_man_above_threshold() {
736        let t = Tier2Input {
737            fan_in: Some(20),
738            ..t2_none()
739        };
740        let p = classify(&t1(0, 0, 15, 0, 0), &t, &th());
741        assert!(has(&p, "middle_man"));
742    }
743
744    // ---------- neighbor_risk ----------
745
746    #[test]
747    fn neighbor_risk_below_threshold() {
748        // churn below
749        let t = Tier2Input {
750            neighbor_churn: Some(399),
751            ..t2_none()
752        };
753        let p = classify(&t1(0, 0, 8, 0, 0), &t, &th());
754        assert!(!has(&p, "neighbor_risk"));
755        // fo below
756        let t = Tier2Input {
757            neighbor_churn: Some(400),
758            ..t2_none()
759        };
760        let p = classify(&t1(0, 0, 7, 0, 0), &t, &th());
761        assert!(!has(&p, "neighbor_risk"));
762    }
763
764    #[test]
765    fn neighbor_risk_at_threshold() {
766        let t = Tier2Input {
767            neighbor_churn: Some(400),
768            ..t2_none()
769        };
770        let p = classify(&t1(0, 0, 8, 0, 0), &t, &th());
771        assert!(has(&p, "neighbor_risk"));
772    }
773
774    #[test]
775    fn neighbor_risk_above_threshold() {
776        let t = Tier2Input {
777            neighbor_churn: Some(1000),
778            ..t2_none()
779        };
780        let p = classify(&t1(0, 0, 20, 0, 0), &t, &th());
781        assert!(has(&p, "neighbor_risk"));
782    }
783
784    // ---------- shotgun_target ----------
785
786    #[test]
787    fn shotgun_target_below_threshold() {
788        // fan_in below
789        let t = Tier2Input {
790            fan_in: Some(7),
791            churn_lines: Some(150),
792            ..t2_none()
793        };
794        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
795        assert!(!has(&p, "shotgun_target"));
796        // churn below
797        let t = Tier2Input {
798            fan_in: Some(8),
799            churn_lines: Some(149),
800            ..t2_none()
801        };
802        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
803        assert!(!has(&p, "shotgun_target"));
804    }
805
806    #[test]
807    fn shotgun_target_at_threshold() {
808        let t = Tier2Input {
809            fan_in: Some(8),
810            churn_lines: Some(150),
811            ..t2_none()
812        };
813        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
814        assert!(has(&p, "shotgun_target"));
815    }
816
817    #[test]
818    fn shotgun_target_above_threshold() {
819        let t = Tier2Input {
820            fan_in: Some(30),
821            churn_lines: Some(500),
822            ..t2_none()
823        };
824        let p = classify(&t1(0, 0, 0, 0, 0), &t, &th());
825        assert!(has(&p, "shotgun_target"));
826    }
827
828    // ---------- stale_complex ----------
829
830    #[test]
831    fn stale_complex_below_threshold() {
832        // cc below
833        let t = Tier2Input {
834            days_since_last_change: Some(180),
835            ..t2_none()
836        };
837        let p = classify(&t1(9, 0, 0, 0, 60), &t, &th());
838        assert!(!has(&p, "stale_complex"));
839        // loc below
840        let t = Tier2Input {
841            days_since_last_change: Some(180),
842            ..t2_none()
843        };
844        let p = classify(&t1(10, 0, 0, 0, 59), &t, &th());
845        assert!(!has(&p, "stale_complex"));
846        // days below
847        let t = Tier2Input {
848            days_since_last_change: Some(179),
849            ..t2_none()
850        };
851        let p = classify(&t1(10, 0, 0, 0, 60), &t, &th());
852        assert!(!has(&p, "stale_complex"));
853    }
854
855    #[test]
856    fn stale_complex_at_threshold() {
857        let t = Tier2Input {
858            days_since_last_change: Some(180),
859            ..t2_none()
860        };
861        let p = classify(&t1(10, 0, 0, 0, 60), &t, &th());
862        assert!(has(&p, "stale_complex"));
863    }
864
865    #[test]
866    fn stale_complex_above_threshold() {
867        let t = Tier2Input {
868            days_since_last_change: Some(365),
869            ..t2_none()
870        };
871        let p = classify(&t1(20, 0, 0, 0, 200), &t, &th());
872        assert!(has(&p, "stale_complex"));
873    }
874
875    // ---------- volatile_god (derived) ----------
876
877    #[test]
878    fn volatile_god_only_god_does_not_fire() {
879        // god_function fires, churn_magnet does not
880        let t = Tier2Input {
881            churn_lines: Some(100),
882            ..t2_none()
883        }; // churn < 200
884        let p = classify(&t1(8, 0, 10, 0, 60), &t, &th());
885        assert!(has(&p, "god_function"));
886        assert!(!has(&p, "volatile_god"));
887    }
888
889    #[test]
890    fn volatile_god_only_churn_does_not_fire() {
891        // churn_magnet fires, god_function does not
892        let t = Tier2Input {
893            churn_lines: Some(200),
894            ..t2_none()
895        };
896        let p = classify(&t1(8, 0, 5, 0, 30), &t, &th()); // loc=30, fo=5 — god doesn't fire
897        assert!(has(&p, "churn_magnet"));
898        assert!(!has(&p, "volatile_god"));
899    }
900
901    #[test]
902    fn volatile_god_both_fire() {
903        let t = Tier2Input {
904            churn_lines: Some(200),
905            ..t2_none()
906        };
907        let p = classify(&t1(8, 0, 10, 0, 60), &t, &th());
908        assert!(has(&p, "god_function"));
909        assert!(has(&p, "churn_magnet"));
910        assert!(has(&p, "volatile_god"));
911    }
912
913    // ---------- entrypoint suppression ----------
914
915    #[test]
916    fn middle_man_suppressed_for_entrypoint() {
917        let base = Tier2Input {
918            fan_in: Some(8),
919            is_entrypoint: false,
920            ..t2_none()
921        };
922        let p = classify(&t1(4, 0, 8, 0, 0), &base, &th());
923        assert!(has(&p, "middle_man"), "should fire when not entrypoint");
924
925        let ep = Tier2Input {
926            is_entrypoint: true,
927            ..base
928        };
929        let p = classify(&t1(4, 0, 8, 0, 0), &ep, &th());
930        assert!(!has(&p, "middle_man"), "should not fire for entrypoint");
931    }
932
933    #[test]
934    fn neighbor_risk_suppressed_for_entrypoint() {
935        let base = Tier2Input {
936            neighbor_churn: Some(400),
937            is_entrypoint: false,
938            ..t2_none()
939        };
940        let p = classify(&t1(0, 0, 8, 0, 0), &base, &th());
941        assert!(has(&p, "neighbor_risk"), "should fire when not entrypoint");
942
943        let ep = Tier2Input {
944            is_entrypoint: true,
945            ..base
946        };
947        let p = classify(&t1(0, 0, 8, 0, 0), &ep, &th());
948        assert!(!has(&p, "neighbor_risk"), "should not fire for entrypoint");
949    }
950
951    // ---------- ordering ----------
952
953    #[test]
954    fn all_tier1_ordering() {
955        // Triggers: complex_branching (cc=10,nd=5), deeply_nested (nd=5),
956        //           exit_heavy (ns=5), god_function (loc=80,fo=10), long_function (loc=80)
957        let p = classify(&t1(10, 5, 10, 5, 80), &t2_none(), &th());
958        assert_eq!(
959            p,
960            vec![
961                "complex_branching",
962                "deeply_nested",
963                "exit_heavy",
964                "god_function",
965                "long_function",
966            ]
967        );
968    }
969
970    // ---------- classify_detailed ----------
971
972    #[test]
973    fn classify_detailed_god_function() {
974        let details = classify_detailed(&t1(0, 0, 12, 0, 85), &t2_none(), &th());
975        let god = details.iter().find(|d| d.id == "god_function").unwrap();
976        assert_eq!(god.tier, 1);
977        assert_eq!(god.kind, "primitive");
978        assert_eq!(god.triggered_by.len(), 2);
979
980        let loc_tb = god.triggered_by.iter().find(|t| t.metric == "LOC").unwrap();
981        assert_eq!(loc_tb.value, 85);
982        assert_eq!(loc_tb.threshold, 60);
983        assert_eq!(loc_tb.op, ">=");
984
985        let fo_tb = god.triggered_by.iter().find(|t| t.metric == "FO").unwrap();
986        assert_eq!(fo_tb.value, 12);
987        assert_eq!(fo_tb.threshold, 10);
988    }
989
990    #[test]
991    fn classify_delegates_to_classify_detailed() {
992        // classify() and classify_detailed() must agree on which IDs fire
993        let t1_in = t1(10, 5, 10, 5, 80);
994        let t2_in = t2_none();
995        let ids = classify(&t1_in, &t2_in, &th());
996        let detail_ids: Vec<String> = classify_detailed(&t1_in, &t2_in, &th())
997            .into_iter()
998            .map(|d| d.id)
999            .collect();
1000        assert_eq!(ids, detail_ids);
1001    }
1002}