Skip to main content

debtmap/risk/
mod.rs

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