1use serde::{Deserialize, Serialize};
8
9pub struct Tier1Input {
12 pub cc: usize,
13 pub nd: usize,
14 pub fo: usize,
15 pub ns: usize,
16 pub loc: usize,
17}
18
19pub 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 pub is_entrypoint: bool,
30}
31
32#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct PatternDetail {
108 pub id: String,
109 pub tier: u8,
111 pub kind: String,
113 pub triggered_by: Vec<TriggeredBy>,
114}
115
116pub 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
127pub fn classify_detailed(t1: &Tier1Input, t2: &Tier2Input, th: &Thresholds) -> Vec<PatternDetail> {
132 let mut results = Vec::new();
133
134 let god = check_god_function(t1, th);
136 let churn = check_churn_magnet(t1, t2, th);
137
138 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 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 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
194fn 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
267fn 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
407fn 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 #[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 #[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 #[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 #[test]
532 fn god_function_below_threshold() {
533 let p = classify(&t1(0, 0, 10, 0, 59), &t2_none(), &th());
535 assert!(!has(&p, "god_function"));
536 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 #[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 #[test]
576 fn churn_magnet_below_threshold() {
577 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 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 #[test]
616 fn cyclic_hub_below_threshold() {
617 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 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 #[test]
660 fn hub_function_below_threshold() {
661 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 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 #[test]
700 fn middle_man_below_threshold() {
701 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 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 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 #[test]
747 fn neighbor_risk_below_threshold() {
748 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 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 #[test]
787 fn shotgun_target_below_threshold() {
788 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 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 #[test]
831 fn stale_complex_below_threshold() {
832 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 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 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 #[test]
878 fn volatile_god_only_god_does_not_fire() {
879 let t = Tier2Input {
881 churn_lines: Some(100),
882 ..t2_none()
883 }; 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 let t = Tier2Input {
893 churn_lines: Some(200),
894 ..t2_none()
895 };
896 let p = classify(&t1(8, 0, 5, 0, 30), &t, &th()); 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 #[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 #[test]
954 fn all_tier1_ordering() {
955 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 #[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 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}