debtmap/risk/
mod.rs

1pub mod context;
2pub mod correlation;
3pub mod coverage_gap;
4pub mod coverage_index;
5pub mod delegation;
6pub mod effects;
7pub mod evidence;
8pub mod evidence_calculator;
9pub mod function_name_matching;
10pub mod insights;
11pub mod lcov;
12pub mod path_normalization;
13pub mod priority;
14pub mod roi;
15pub mod strategy;
16pub mod thresholds;
17
18use crate::core::ComplexityMetrics;
19use im::Vector;
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
24pub struct FunctionRisk {
25    pub file: PathBuf,
26    pub function_name: String,
27    pub line_range: (usize, usize),
28    pub cyclomatic_complexity: u32,
29    pub cognitive_complexity: u32,
30    pub coverage_percentage: Option<f64>,
31    pub risk_score: f64,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub contextual_risk: Option<context::ContextualRisk>,
34    pub test_effort: TestEffort,
35    pub risk_category: RiskCategory,
36    pub is_test_function: bool,
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
40pub enum RiskCategory {
41    Critical,   // High complexity (>15), low coverage (<30%)
42    High,       // High complexity (>10), moderate coverage (<60%)
43    Medium,     // Moderate complexity (>5), low coverage (<50%)
44    Low,        // Low complexity or high coverage
45    WellTested, // High complexity with high coverage (good examples)
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct TestEffort {
50    pub estimated_difficulty: Difficulty,
51    pub cognitive_load: u32,
52    pub branch_count: u32,
53    pub recommended_test_cases: u32,
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
57pub enum Difficulty {
58    Trivial,     // Cognitive < 5
59    Simple,      // Cognitive 5-10
60    Moderate,    // Cognitive 10-20
61    Complex,     // Cognitive 20-40
62    VeryComplex, // Cognitive > 40
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct RiskInsight {
67    pub top_risks: Vector<FunctionRisk>,
68    pub risk_reduction_opportunities: Vector<TestingRecommendation>,
69    pub codebase_risk_score: f64,
70    pub complexity_coverage_correlation: Option<f64>,
71    pub risk_distribution: RiskDistribution,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct TestingRecommendation {
76    pub function: String,
77    pub file: PathBuf,
78    pub line: usize,
79    pub current_risk: f64,
80    pub potential_risk_reduction: f64,
81    pub test_effort_estimate: TestEffort,
82    pub rationale: String,
83    pub roi: Option<f64>,
84    pub dependencies: Vec<String>,
85    pub dependents: Vec<String>,
86}
87
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct RiskDistribution {
90    pub critical_count: usize,
91    pub high_count: usize,
92    pub medium_count: usize,
93    pub low_count: usize,
94    pub well_tested_count: usize,
95    pub total_functions: usize,
96}
97
98use self::context::{AnalysisTarget, ContextAggregator, ContextualRisk};
99use self::strategy::{EnhancedRiskStrategy, RiskCalculator, RiskContext};
100use std::sync::Arc;
101
102pub struct RiskAnalyzer {
103    strategy: Box<dyn RiskCalculator>,
104    debt_score: Option<f64>,
105    debt_threshold: Option<f64>,
106    context_aggregator: Option<Arc<ContextAggregator>>,
107}
108
109impl Clone for RiskAnalyzer {
110    /// Clone the risk analyzer, preserving context aggregator.
111    ///
112    /// The context aggregator is wrapped in Arc, so cloning is cheap (just
113    /// an atomic reference count increment) and preserves the shared cache.
114    fn clone(&self) -> Self {
115        Self {
116            strategy: self.strategy.box_clone(),
117            debt_score: self.debt_score,
118            debt_threshold: self.debt_threshold,
119            context_aggregator: self.context_aggregator.clone(), // Arc::clone is cheap!
120        }
121    }
122}
123
124impl Default for RiskAnalyzer {
125    fn default() -> Self {
126        Self {
127            strategy: Box::new(EnhancedRiskStrategy::default()),
128            debt_score: None,
129            debt_threshold: None,
130            context_aggregator: None,
131        }
132    }
133}
134
135impl RiskAnalyzer {
136    pub fn with_debt_context(mut self, debt_score: f64, debt_threshold: f64) -> Self {
137        self.debt_score = Some(debt_score);
138        self.debt_threshold = Some(debt_threshold);
139        self
140    }
141
142    pub fn with_context_aggregator(mut self, aggregator: ContextAggregator) -> Self {
143        self.context_aggregator = Some(Arc::new(aggregator));
144        self
145    }
146
147    pub fn has_context(&self) -> bool {
148        self.context_aggregator.is_some()
149    }
150
151    pub fn analyze_function(
152        &self,
153        file: PathBuf,
154        function_name: String,
155        line_range: (usize, usize),
156        complexity: &ComplexityMetrics,
157        coverage: Option<f64>,
158        is_test: bool,
159    ) -> FunctionRisk {
160        let context = RiskContext {
161            file,
162            function_name,
163            line_range,
164            complexity: complexity.clone(),
165            coverage,
166            debt_score: self.debt_score,
167            debt_threshold: self.debt_threshold,
168            is_test,
169            is_recognized_pattern: false,
170            pattern_type: None,
171            pattern_confidence: 0.0,
172        };
173
174        self.strategy.calculate(&context)
175    }
176
177    #[allow(clippy::too_many_arguments)]
178    pub fn analyze_function_with_context(
179        &self,
180        file: PathBuf,
181        function_name: String,
182        line_range: (usize, usize),
183        complexity: &ComplexityMetrics,
184        coverage: Option<f64>,
185        is_test: bool,
186        root_path: PathBuf,
187    ) -> (FunctionRisk, Option<ContextualRisk>) {
188        let mut base_risk = self.analyze_function(
189            file.clone(),
190            function_name.clone(),
191            line_range,
192            complexity,
193            coverage,
194            is_test,
195        );
196
197        let contextual_risk = if let Some(ref aggregator) = self.context_aggregator {
198            let target = AnalysisTarget {
199                root_path,
200                file_path: file,
201                function_name: function_name.clone(),
202                line_range,
203            };
204
205            let context_map = aggregator.analyze(&target);
206            let ctx_risk = ContextualRisk::new(base_risk.risk_score, &context_map);
207
208            // Update the FunctionRisk with contextual data
209            base_risk.contextual_risk = Some(ctx_risk.clone());
210            base_risk.risk_score = ctx_risk.contextual_risk;
211
212            // Verbose logging for context contributions
213            if log::log_enabled!(log::Level::Debug) {
214                log::debug!(
215                    "Context analysis for {}::{}: base_risk={:.1}, contextual_risk={:.1}, multiplier={:.2}x",
216                    base_risk.file.display(),
217                    function_name,
218                    ctx_risk.base_risk,
219                    ctx_risk.contextual_risk,
220                    ctx_risk.contextual_risk / ctx_risk.base_risk.max(0.1)
221                );
222
223                for context in &ctx_risk.contexts {
224                    log::debug!(
225                        "  └─ {}: contribution={:.2}, weight={:.1}, impact=+{:.1}",
226                        context.provider,
227                        context.contribution,
228                        context.weight,
229                        context.contribution * context.weight
230                    );
231                }
232            }
233
234            Some(ctx_risk)
235        } else {
236            None
237        };
238
239        (base_risk, contextual_risk)
240    }
241
242    pub fn calculate_risk_score(
243        &self,
244        cyclomatic: u32,
245        cognitive: u32,
246        coverage: Option<f64>,
247    ) -> f64 {
248        let context = RiskContext {
249            file: PathBuf::new(),
250            function_name: String::new(),
251            line_range: (0, 0),
252            complexity: ComplexityMetrics {
253                functions: vec![],
254                cyclomatic_complexity: cyclomatic,
255                cognitive_complexity: cognitive,
256            },
257            coverage,
258            debt_score: self.debt_score,
259            debt_threshold: self.debt_threshold,
260            is_test: false,
261            is_recognized_pattern: false,
262            pattern_type: None,
263            pattern_confidence: 0.0,
264        };
265
266        self.strategy.calculate_risk_score(&context)
267    }
268
269    pub fn calculate_risk_reduction(
270        &self,
271        current_risk: f64,
272        complexity: u32,
273        target_coverage: f64,
274    ) -> f64 {
275        self.strategy
276            .calculate_risk_reduction(current_risk, complexity, target_coverage)
277    }
278
279    /// Analyze file-level contextual risk for god objects.
280    ///
281    /// This method specifically handles file-level analysis where there is no
282    /// specific function being analyzed. It's designed for god objects where
283    /// the entire file represents the technical debt unit.
284    ///
285    /// # Arguments
286    /// * `file_path` - Path to the file being analyzed
287    /// * `base_risk` - Base risk score for the god object (from god object scoring)
288    /// * `root_path` - Project root path
289    ///
290    /// # Returns
291    /// `Some(ContextualRisk)` if context analysis is enabled, `None` otherwise
292    pub fn analyze_file_context(
293        &self,
294        file_path: PathBuf,
295        base_risk: f64,
296        root_path: PathBuf,
297    ) -> Option<ContextualRisk> {
298        let aggregator = self.context_aggregator.as_ref()?;
299
300        let target = AnalysisTarget {
301            root_path,
302            file_path,
303            function_name: String::new(), // Empty for file-level analysis
304            line_range: (0, 0),           // Not applicable for file-level
305        };
306
307        let context_map = aggregator.analyze(&target);
308        Some(ContextualRisk::new(base_risk, &context_map))
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_risk_analyzer_clone_preserves_context() {
318        let aggregator = ContextAggregator::new();
319
320        let analyzer = RiskAnalyzer::default().with_context_aggregator(aggregator);
321
322        let cloned = analyzer.clone();
323
324        assert!(cloned.has_context());
325    }
326
327    /// Stress test: analyze many functions with context to detect stack overflow.
328    /// This simulates what happens when running `debtmap analyze --context`
329    /// on a large codebase like debtmap itself (~4000 functions).
330    #[test]
331    #[ignore] // Takes >2 minutes - run with `cargo test -- --ignored`
332    fn test_analyze_many_functions_with_context_no_stack_overflow() {
333        use crate::core::ComplexityMetrics;
334        use crate::priority::call_graph::CallGraph;
335        use crate::risk::context::critical_path::{
336            CriticalPathAnalyzer, CriticalPathProvider, EntryPoint, EntryType,
337        };
338        use crate::risk::context::dependency::{DependencyGraph, DependencyRiskProvider};
339
340        // Build a realistic call graph
341        let mut call_graph = CallGraph::new();
342        for i in 0..2000 {
343            let caller = format!("func_{}", i);
344            let callee = format!("func_{}", i + 1);
345            call_graph.add_edge_by_name(caller, callee, PathBuf::from("src/lib.rs"));
346        }
347
348        // Create critical path analyzer with entry point
349        let mut cp_analyzer = CriticalPathAnalyzer::new();
350        cp_analyzer.call_graph = call_graph;
351        cp_analyzer.entry_points.push_back(EntryPoint {
352            function_name: "func_0".to_string(),
353            file_path: PathBuf::from("src/main.rs"),
354            entry_type: EntryType::Main,
355            is_user_facing: true,
356        });
357
358        // Build aggregator with providers
359        let aggregator = ContextAggregator::new()
360            .with_provider(Box::new(CriticalPathProvider::new(cp_analyzer)))
361            .with_provider(Box::new(
362                DependencyRiskProvider::new(DependencyGraph::new()),
363            ));
364
365        // Create risk analyzer with context
366        let analyzer = RiskAnalyzer::default().with_context_aggregator(aggregator);
367
368        // Analyze many functions - this is what crashes in production
369        for i in 0..500 {
370            let complexity = ComplexityMetrics {
371                functions: vec![],
372                cyclomatic_complexity: 10,
373                cognitive_complexity: 15,
374            };
375
376            let (_risk, contextual) = analyzer.analyze_function_with_context(
377                PathBuf::from(format!("src/module_{}.rs", i % 50)),
378                format!("func_{}", i),
379                (1, 50),
380                &complexity,
381                Some(0.75),
382                false,
383                PathBuf::from("/project"),
384            );
385
386            // Verify we got context
387            if i < 100 {
388                // First 100 should be cache misses
389                assert!(
390                    contextual.is_some(),
391                    "Should get contextual risk for function {}",
392                    i
393                );
394            }
395        }
396    }
397
398    /// Test file-level context analysis (for god objects) at scale
399    #[test]
400    fn test_analyze_file_context_many_files_no_stack_overflow() {
401        use crate::risk::context::critical_path::{
402            CriticalPathAnalyzer, CriticalPathProvider, EntryPoint, EntryType,
403        };
404        use crate::risk::context::dependency::{DependencyGraph, DependencyRiskProvider};
405
406        // Create aggregator
407        let mut cp_analyzer = CriticalPathAnalyzer::new();
408        cp_analyzer.entry_points.push_back(EntryPoint {
409            function_name: "main".to_string(),
410            file_path: PathBuf::from("src/main.rs"),
411            entry_type: EntryType::Main,
412            is_user_facing: true,
413        });
414
415        let aggregator = ContextAggregator::new()
416            .with_provider(Box::new(CriticalPathProvider::new(cp_analyzer)))
417            .with_provider(Box::new(
418                DependencyRiskProvider::new(DependencyGraph::new()),
419            ));
420
421        let analyzer = RiskAnalyzer::default().with_context_aggregator(aggregator);
422
423        // Analyze many files - simulates god object analysis
424        for i in 0..200 {
425            let result = analyzer.analyze_file_context(
426                PathBuf::from(format!("src/large_file_{}.rs", i)),
427                40.0,
428                PathBuf::from("/project"),
429            );
430
431            assert!(result.is_some(), "Should get context for file {}", i);
432        }
433    }
434
435    /// Minimal mock provider for testing
436    struct MockProvider {
437        name: &'static str,
438    }
439
440    impl context::ContextProvider for MockProvider {
441        fn name(&self) -> &str {
442            self.name
443        }
444
445        fn gather(&self, _target: &context::AnalysisTarget) -> anyhow::Result<context::Context> {
446            Ok(context::Context {
447                provider: self.name.to_string(),
448                weight: 1.0,
449                contribution: 0.5,
450                details: context::ContextDetails::Historical {
451                    change_frequency: 0.1,
452                    bug_density: 0.05,
453                    age_days: 100,
454                    author_count: 3,
455                },
456            })
457        }
458
459        fn weight(&self) -> f64 {
460            1.0
461        }
462
463        fn explain(&self, _context: &context::Context) -> String {
464            "mock".to_string()
465        }
466    }
467
468    /// Test with 3 providers (matching production: critical_path, dependency, git_history)
469    #[test]
470    fn test_three_providers_many_iterations() {
471        // Build aggregator with 3 mock providers
472        let aggregator = ContextAggregator::new()
473            .with_provider(Box::new(MockProvider {
474                name: "critical_path",
475            }))
476            .with_provider(Box::new(MockProvider {
477                name: "dependency_risk",
478            }))
479            .with_provider(Box::new(MockProvider {
480                name: "git_history",
481            }));
482
483        let analyzer = RiskAnalyzer::default().with_context_aggregator(aggregator);
484
485        // Run many iterations - each should be independent
486        for i in 0..5000 {
487            let result = analyzer.analyze_file_context(
488                PathBuf::from(format!("src/file_{}.rs", i)),
489                40.0,
490                PathBuf::from("/project"),
491            );
492
493            assert!(result.is_some(), "Iteration {} should succeed", i);
494        }
495    }
496
497    /// Test parallel execution with rayon - this is closer to production behavior
498    #[test]
499    fn test_parallel_context_analysis_with_rayon() {
500        use rayon::prelude::*;
501
502        // Build aggregator with 3 mock providers
503        let aggregator = ContextAggregator::new()
504            .with_provider(Box::new(MockProvider {
505                name: "critical_path",
506            }))
507            .with_provider(Box::new(MockProvider {
508                name: "dependency_risk",
509            }))
510            .with_provider(Box::new(MockProvider {
511                name: "git_history",
512            }));
513
514        let analyzer = RiskAnalyzer::default().with_context_aggregator(aggregator);
515
516        // Run in parallel - this uses rayon's thread pool with smaller stacks
517        let results: Vec<_> = (0..5000)
518            .into_par_iter()
519            .map(|i| {
520                analyzer.analyze_file_context(
521                    PathBuf::from(format!("src/file_{}.rs", i)),
522                    40.0,
523                    PathBuf::from("/project"),
524                )
525            })
526            .collect();
527
528        assert_eq!(results.len(), 5000);
529        assert!(results.iter().all(|r| r.is_some()));
530    }
531}