Skip to main content

prview/regression/
mod.rs

1//! Regression detection module (v2-v4)
2//!
3//! Computes regression signals across multiple dimensions:
4//! - Diff: file churn, hotspots, status distribution
5//! - Tests: coverage ratio, untested critical files
6//! - Dependencies: cycles/dead_exports/unused_symbols deltas
7//! - Performance: cheap pattern-based heuristics on patches
8//! - Score: unified 0-100 score with explainability
9
10pub mod deps;
11pub mod diff;
12pub mod perf;
13pub mod score;
14pub mod tests;
15
16use serde::{Deserialize, Serialize};
17
18/// Input context for regression computation.
19///
20/// Assembled from the pipeline after diffs, checks, heuristics, and coverage
21/// have been computed.
22#[derive(Debug, Clone, Default)]
23pub struct RegressionContext {
24    // Diff stats
25    pub files_changed: usize,
26    pub insertions: usize,
27    pub deletions: usize,
28    /// Per-file churn: (path, status_char, additions, deletions)
29    pub file_stats: Vec<(String, char, usize, usize)>,
30
31    // Coverage stats
32    pub coverage_ratio: Option<f64>,
33    pub base_coverage_ratio: Option<f64>,
34    pub untested_critical_files: Vec<String>,
35    pub base_untested_critical_count: Option<usize>,
36
37    // Heuristics stats (current run)
38    pub cycles: usize,
39    pub dead_exports: usize,
40    pub unused_symbols: usize,
41
42    // Heuristics stats (base run, if available)
43    pub base_cycles: Option<usize>,
44    pub base_dead_exports: Option<usize>,
45    pub base_unused_symbols: Option<usize>,
46
47    // Heuristics detail lists (capped)
48    pub top_cycles: Vec<String>,
49    pub top_unused: Vec<String>,
50
51    // Patch text for perf analysis
52    pub patch_text: Option<String>,
53}
54
55/// Full regression report with sub-reports for each dimension.
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct RegressionReport {
58    pub diff: diff::DiffRegression,
59    pub tests: tests::TestRegression,
60    pub deps: deps::DepsRegression,
61    pub perf: perf::PerfRegression,
62    pub score: score::RegressionScore,
63}
64
65/// Compute full regression report from context.
66pub fn compute_regression(ctx: &RegressionContext) -> RegressionReport {
67    let diff_reg = diff::analyze(ctx);
68    let test_reg = tests::analyze(ctx);
69    let deps_reg = deps::analyze(ctx);
70    let perf_reg = perf::analyze(ctx);
71    let score_reg = score::compute(&diff_reg, &test_reg, &deps_reg, &perf_reg);
72
73    RegressionReport {
74        diff: diff_reg,
75        tests: test_reg,
76        deps: deps_reg,
77        perf: perf_reg,
78        score: score_reg,
79    }
80}
81
82#[cfg(test)]
83mod regression_tests {
84    use super::*;
85    use score::Severity;
86
87    #[test]
88    fn test_compute_regression_diff_only() {
89        let ctx = RegressionContext {
90            files_changed: 5,
91            insertions: 100,
92            deletions: 50,
93            file_stats: vec![
94                ("src/main.rs".into(), 'M', 80, 20),
95                ("src/lib.rs".into(), 'M', 10, 10),
96                ("src/new.rs".into(), 'A', 10, 0),
97                ("README.md".into(), 'M', 0, 10),
98                ("old.rs".into(), 'D', 0, 10),
99            ],
100            ..Default::default()
101        };
102
103        let report = compute_regression(&ctx);
104
105        assert_eq!(report.diff.files_changed, 5);
106        assert_eq!(report.diff.insertions, 100);
107        assert_eq!(report.diff.deletions, 50);
108        assert_eq!(report.diff.max_file_churn, 100); // src/main.rs: 80+20
109        assert!(!report.diff.diff_regression_detected);
110        assert_eq!(report.diff.top_hotspots.len(), 5);
111        assert_eq!(report.diff.top_hotspots[0].file, "src/main.rs");
112        assert_eq!(report.diff.changed_files_by_status.get("M"), Some(&3));
113        assert_eq!(report.diff.changed_files_by_status.get("A"), Some(&1));
114        assert_eq!(report.diff.changed_files_by_status.get("D"), Some(&1));
115    }
116
117    #[test]
118    fn test_compute_regression_high_churn() {
119        let ctx = RegressionContext {
120            files_changed: 60,
121            insertions: 3000,
122            deletions: 1000,
123            file_stats: vec![("big.rs".into(), 'M', 600, 200)],
124            ..Default::default()
125        };
126
127        let report = compute_regression(&ctx);
128        assert!(report.diff.diff_regression_detected);
129        assert_eq!(report.diff.max_file_churn, 800);
130    }
131
132    #[test]
133    fn test_score_severity_ok() {
134        let ctx = RegressionContext {
135            files_changed: 3,
136            insertions: 10,
137            deletions: 5,
138            file_stats: vec![("a.rs".into(), 'M', 10, 5)],
139            ..Default::default()
140        };
141
142        let report = compute_regression(&ctx);
143        assert_eq!(report.score.severity, Severity::OK);
144        assert_eq!(report.score.score, 0);
145    }
146
147    #[test]
148    fn test_score_severity_med() {
149        let ctx = RegressionContext {
150            files_changed: 55,
151            insertions: 2600,
152            deletions: 400,
153            file_stats: vec![("big.rs".into(), 'M', 600, 200)],
154            untested_critical_files: vec!["a.rs".into(), "b.rs".into(), "c.rs".into()],
155            cycles: 5,
156            base_cycles: Some(2),
157            ..Default::default()
158        };
159
160        let report = compute_regression(&ctx);
161        assert!(report.score.score > 10);
162        assert!(matches!(
163            report.score.severity,
164            Severity::LOW | Severity::MED | Severity::HIGH
165        ));
166        assert!(!report.score.score_reasons.is_empty());
167    }
168
169    #[test]
170    fn test_score_severity_critical() {
171        let ctx = RegressionContext {
172            files_changed: 200,
173            insertions: 10000,
174            deletions: 5000,
175            file_stats: vec![("huge.rs".into(), 'M', 5000, 3000)],
176            coverage_ratio: Some(0.2),
177            base_coverage_ratio: Some(0.8),
178            untested_critical_files: (0..10)
179                .map(|i| format!("file_{}.rs", i))
180                .collect(),
181            cycles: 20,
182            base_cycles: Some(5),
183            dead_exports: 30,
184            base_dead_exports: Some(10),
185            unused_symbols: 40,
186            base_unused_symbols: Some(10),
187            patch_text: Some(
188                "diff --git a/big.rs b/big.rs\n+++ b/big.rs\n@@ -1,3 +1,5 @@\n+for item in items {\n+    db.execute(query);\n+}\n"
189                    .to_string(),
190            ),
191            ..Default::default()
192        };
193
194        let report = compute_regression(&ctx);
195        assert!(report.score.score >= 76);
196        assert_eq!(report.score.severity, Severity::CRITICAL);
197    }
198
199    #[test]
200    fn test_coverage_delta_detection() {
201        let ctx = RegressionContext {
202            coverage_ratio: Some(0.5),
203            base_coverage_ratio: Some(0.8),
204            ..Default::default()
205        };
206
207        let report = compute_regression(&ctx);
208        assert!(report.tests.coverage_regression_detected);
209        assert!(report.tests.coverage_delta.unwrap() < -0.01);
210    }
211
212    #[test]
213    fn test_deps_regression_detection() {
214        let ctx = RegressionContext {
215            cycles: 5,
216            base_cycles: Some(2),
217            dead_exports: 10,
218            base_dead_exports: Some(10),
219            unused_symbols: 8,
220            base_unused_symbols: Some(3),
221            ..Default::default()
222        };
223
224        let report = compute_regression(&ctx);
225        assert!(report.deps.dependency_regression_detected);
226        assert_eq!(report.deps.cycles_delta, 3);
227        assert_eq!(report.deps.dead_exports_delta, 0);
228        assert_eq!(report.deps.unused_symbols_delta, 5);
229    }
230
231    #[test]
232    fn test_perf_query_in_loop() {
233        let patch = r#"diff --git a/src/handler.rs b/src/handler.rs
234+++ b/src/handler.rs
235@@ -10,3 +10,6 @@
236+for user in users {
237+    let result = db.query("SELECT * FROM orders WHERE user_id = ?");
238+}
239"#;
240
241        let ctx = RegressionContext {
242            patch_text: Some(patch.to_string()),
243            ..Default::default()
244        };
245
246        let report = compute_regression(&ctx);
247        assert!(report.perf.perf_regression_suspected);
248        assert_eq!(report.perf.query_in_loop_count, 1);
249    }
250
251    #[test]
252    fn test_perf_clone_collect_in_loop() {
253        let patch = r#"diff --git a/src/process.rs b/src/process.rs
254+++ b/src/process.rs
255@@ -5,3 +5,5 @@
256+for item in items.iter() {
257+    let cloned = item.clone();
258+    let collected: Vec<_> = data.iter().collect();
259+}
260"#;
261
262        let ctx = RegressionContext {
263            patch_text: Some(patch.to_string()),
264            ..Default::default()
265        };
266
267        let report = compute_regression(&ctx);
268        assert!(report.perf.perf_regression_suspected);
269        assert!(report.perf.clone_collect_in_loop_count > 0);
270    }
271
272    #[test]
273    fn test_empty_context() {
274        let ctx = RegressionContext::default();
275        let report = compute_regression(&ctx);
276
277        assert_eq!(report.score.severity, Severity::OK);
278        assert_eq!(report.score.score, 0);
279        assert!(!report.diff.diff_regression_detected);
280        assert!(!report.tests.coverage_regression_detected);
281        assert!(!report.deps.dependency_regression_detected);
282        assert!(!report.perf.perf_regression_suspected);
283    }
284
285    #[test]
286    fn test_regression_report_round_trip() {
287        let ctx = RegressionContext {
288            files_changed: 3,
289            insertions: 50,
290            deletions: 20,
291            file_stats: vec![("a.rs".into(), 'M', 50, 20)],
292            ..Default::default()
293        };
294
295        let report = compute_regression(&ctx);
296        let json = serde_json::to_string_pretty(&report).unwrap();
297        assert!(json.contains("\"score\""));
298        assert!(json.contains("\"severity\""));
299
300        // Round-trip: deserialize back and verify
301        let deserialized: RegressionReport = serde_json::from_str(&json).unwrap();
302        assert_eq!(deserialized.score.score, report.score.score);
303        assert_eq!(deserialized.score.severity, report.score.severity);
304        assert_eq!(deserialized.diff.files_changed, report.diff.files_changed);
305        assert_eq!(
306            deserialized.perf.perf_regression_suspected,
307            report.perf.perf_regression_suspected
308        );
309    }
310
311    #[test]
312    fn test_score_cap_at_100() {
313        // All dimensions maxed far beyond caps
314        let ctx = RegressionContext {
315            files_changed: 500,
316            insertions: 50000,
317            deletions: 20000,
318            file_stats: vec![("huge.rs".into(), 'M', 50000, 20000)],
319            coverage_ratio: Some(0.0),
320            base_coverage_ratio: Some(1.0),
321            untested_critical_files: (0..50)
322                .map(|i| format!("f{}.rs", i))
323                .collect(),
324            cycles: 100,
325            base_cycles: Some(0),
326            dead_exports: 100,
327            base_dead_exports: Some(0),
328            unused_symbols: 100,
329            base_unused_symbols: Some(0),
330            patch_text: Some(
331                "diff --git a/x.rs b/x.rs\n+++ b/x.rs\n@@ -1,1 +1,3 @@\n+for x in items.iter() {\n+    db.execute(q).clone();\n+    db.query(q2).collect();\n+}\n"
332                    .to_string(),
333            ),
334            ..Default::default()
335        };
336
337        let report = compute_regression(&ctx);
338        // Score is capped at 100; with all dimensions maxed we get ~87
339        // (each dimension has individual caps that sum to ~95)
340        assert!(
341            report.score.score > 75,
342            "Expected high score, got {}",
343            report.score.score
344        );
345        assert!(report.score.score <= 100, "Score must not exceed 100");
346        assert_eq!(report.score.severity, Severity::CRITICAL);
347    }
348
349    #[test]
350    fn test_deps_no_base_returns_zero_deltas() {
351        let ctx = RegressionContext {
352            cycles: 10,
353            dead_exports: 5,
354            unused_symbols: 3,
355            // All base_* fields are None (default)
356            ..Default::default()
357        };
358
359        let report = compute_regression(&ctx);
360        assert_eq!(report.deps.cycles_delta, 0);
361        assert_eq!(report.deps.dead_exports_delta, 0);
362        assert_eq!(report.deps.unused_symbols_delta, 0);
363        assert!(!report.deps.dependency_regression_detected);
364        assert_eq!(report.deps.current_cycles, 10);
365    }
366}