ruchy/quality/
gates.rs

1//! Quality gate enforcement system (RUCHY-0815)
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use serde::{Deserialize, Serialize};
6use crate::quality::scoring::{QualityScore, Grade};
7
8/// Quality gate configuration
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct QualityGateConfig {
11    /// Minimum overall score required (0.0-1.0)
12    pub min_score: f64,
13    
14    /// Minimum grade required
15    pub min_grade: Grade,
16    
17    /// Component-specific thresholds
18    pub component_thresholds: ComponentThresholds,
19    
20    /// Anti-gaming rules
21    pub anti_gaming: AntiGamingRules,
22    
23    /// CI/CD integration settings
24    pub ci_integration: CiIntegration,
25    
26    /// Project-specific overrides
27    pub project_overrides: HashMap<String, f64>,
28}
29
30/// Component-specific quality thresholds
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ComponentThresholds {
33    /// Minimum correctness score (0.0-1.0)
34    pub correctness: f64,
35    
36    /// Minimum performance score (0.0-1.0)
37    pub performance: f64,
38    
39    /// Minimum maintainability score (0.0-1.0)
40    pub maintainability: f64,
41    
42    /// Minimum safety score (0.0-1.0)
43    pub safety: f64,
44    
45    /// Minimum idiomaticity score (0.0-1.0)
46    pub idiomaticity: f64,
47}
48
49/// Anti-gaming rules to prevent score manipulation
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AntiGamingRules {
52    /// Minimum confidence level required (0.0-1.0)
53    pub min_confidence: f64,
54    
55    /// Maximum cache hit rate allowed (0.0-1.0) - prevents stale analysis
56    pub max_cache_hit_rate: f64,
57    
58    /// Require deep analysis for critical files
59    pub require_deep_analysis: Vec<String>,
60    
61    /// Penalty for files that are too small (gaming by splitting)
62    pub min_file_size_bytes: usize,
63    
64    /// Penalty for excessive test file ratios (gaming with trivial tests)
65    pub max_test_ratio: f64,
66}
67
68/// CI/CD integration configuration
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CiIntegration {
71    /// Fail CI/CD pipeline on gate failure
72    pub fail_on_violation: bool,
73    
74    /// Export results in `JUnit` XML format
75    pub junit_xml: bool,
76    
77    /// Export results in JSON format for tooling
78    pub json_output: bool,
79    
80    /// Send notifications on quality degradation
81    pub notifications: NotificationConfig,
82    
83    /// Block merge requests below threshold
84    pub block_merge: bool,
85}
86
87/// Notification configuration
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct NotificationConfig {
90    /// Enable Slack notifications
91    pub slack: bool,
92    
93    /// Enable email notifications  
94    pub email: bool,
95    
96    /// Webhook URL for custom notifications
97    pub webhook: Option<String>,
98}
99
100/// Quality gate enforcement result
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
102pub struct GateResult {
103    /// Whether the quality gate passed
104    pub passed: bool,
105    
106    /// Overall score achieved
107    pub score: f64,
108    
109    /// Grade achieved
110    pub grade: Grade,
111    
112    /// Specific violations found
113    pub violations: Vec<Violation>,
114    
115    /// Confidence in the result
116    pub confidence: f64,
117    
118    /// Anti-gaming warnings
119    pub gaming_warnings: Vec<String>,
120}
121
122/// Specific quality gate violation
123#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
124pub struct Violation {
125    /// Type of violation
126    pub violation_type: ViolationType,
127    
128    /// Actual value that caused violation
129    pub actual: f64,
130    
131    /// Required threshold
132    pub required: f64,
133    
134    /// Severity of the violation
135    pub severity: Severity,
136    
137    /// Human-readable message
138    pub message: String,
139}
140
141/// Types of quality gate violations
142#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
143pub enum ViolationType {
144    OverallScore,
145    Grade,
146    Correctness,
147    Performance,
148    Maintainability,
149    Safety,
150    Idiomaticity,
151    Confidence,
152    Gaming,
153}
154
155/// Violation severity levels
156#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
157pub enum Severity {
158    Critical, // Must fix to pass
159    High,     // Should fix soon
160    Medium,   // Should improve
161    Low,      // Nice to improve
162}
163
164/// Quality gate enforcer
165pub struct QualityGateEnforcer {
166    config: QualityGateConfig,
167}
168
169impl Default for QualityGateConfig {
170    fn default() -> Self {
171        Self {
172            min_score: 0.7, // B- grade minimum
173            min_grade: Grade::BMinus,
174            component_thresholds: ComponentThresholds {
175                correctness: 0.8,   // High correctness required
176                performance: 0.6,   // Moderate performance required
177                maintainability: 0.7, // Good maintainability required  
178                safety: 0.8,        // High safety required
179                idiomaticity: 0.5,  // Basic idiomaticity required
180            },
181            anti_gaming: AntiGamingRules {
182                min_confidence: 0.6,
183                max_cache_hit_rate: 0.8,
184                require_deep_analysis: vec![
185                    "src/main.rs".to_string(),
186                    "src/lib.rs".to_string(),
187                ],
188                min_file_size_bytes: 100,
189                max_test_ratio: 2.0,
190            },
191            ci_integration: CiIntegration {
192                fail_on_violation: true,
193                junit_xml: true,
194                json_output: true,
195                notifications: NotificationConfig {
196                    slack: false,
197                    email: false,
198                    webhook: None,
199                },
200                block_merge: true,
201            },
202            project_overrides: HashMap::new(),
203        }
204    }
205}
206
207impl QualityGateEnforcer {
208    pub fn new(config: QualityGateConfig) -> Self {
209        Self { config }
210    }
211    
212    /// Load configuration from .ruchy/score.toml
213    pub fn load_config(project_root: &Path) -> anyhow::Result<QualityGateConfig> {
214        let config_path = project_root.join(".ruchy").join("score.toml");
215        
216        if config_path.exists() {
217            let content = std::fs::read_to_string(&config_path)?;
218            let config: QualityGateConfig = toml::from_str(&content)?;
219            Ok(config)
220        } else {
221            // Create default configuration file
222            let default_config = QualityGateConfig::default();
223            std::fs::create_dir_all(project_root.join(".ruchy"))?;
224            let toml_content = toml::to_string_pretty(&default_config)?;
225            std::fs::write(&config_path, toml_content)?;
226            Ok(default_config)
227        }
228    }
229    
230    /// Enforce quality gates on a score
231    pub fn enforce_gates(&self, score: &QualityScore, file_path: Option<&PathBuf>) -> GateResult {
232        let mut violations = Vec::new();
233        let mut gaming_warnings = Vec::new();
234        
235        // Check overall score threshold
236        if score.value < self.config.min_score {
237            violations.push(Violation {
238                violation_type: ViolationType::OverallScore,
239                actual: score.value,
240                required: self.config.min_score,
241                severity: Severity::Critical,
242                message: format!(
243                    "Overall score {:.1}% below minimum {:.1}%",
244                    score.value * 100.0,
245                    self.config.min_score * 100.0
246                ),
247            });
248        }
249        
250        // Check grade requirement
251        if score.grade < self.config.min_grade {
252            violations.push(Violation {
253                violation_type: ViolationType::Grade,
254                actual: score.value,
255                required: self.config.min_score,
256                severity: Severity::Critical,
257                message: format!(
258                    "Grade {} below minimum {}",
259                    score.grade,
260                    self.config.min_grade
261                ),
262            });
263        }
264        
265        // Check component thresholds
266        self.check_component_thresholds(score, &mut violations);
267        
268        // Check anti-gaming rules
269        self.check_anti_gaming_rules(score, file_path, &mut gaming_warnings, &mut violations);
270        
271        // Check confidence threshold
272        if score.confidence < self.config.anti_gaming.min_confidence {
273            violations.push(Violation {
274                violation_type: ViolationType::Confidence,
275                actual: score.confidence,
276                required: self.config.anti_gaming.min_confidence,
277                severity: Severity::High,
278                message: format!(
279                    "Confidence {:.1}% below minimum {:.1}%",
280                    score.confidence * 100.0,
281                    self.config.anti_gaming.min_confidence * 100.0
282                ),
283            });
284        }
285        
286        let passed = violations.iter().all(|v| v.severity != Severity::Critical);
287        
288        GateResult {
289            passed,
290            score: score.value,
291            grade: score.grade,
292            violations,
293            confidence: score.confidence,
294            gaming_warnings,
295        }
296    }
297    
298    fn check_component_thresholds(&self, score: &QualityScore, violations: &mut Vec<Violation>) {
299        let thresholds = &self.config.component_thresholds;
300        
301        if score.components.correctness < thresholds.correctness {
302            violations.push(Violation {
303                violation_type: ViolationType::Correctness,
304                actual: score.components.correctness,
305                required: thresholds.correctness,
306                severity: Severity::Critical,
307                message: format!(
308                    "Correctness {:.1}% below minimum {:.1}%",
309                    score.components.correctness * 100.0,
310                    thresholds.correctness * 100.0
311                ),
312            });
313        }
314        
315        if score.components.performance < thresholds.performance {
316            violations.push(Violation {
317                violation_type: ViolationType::Performance,
318                actual: score.components.performance,
319                required: thresholds.performance,
320                severity: Severity::High,
321                message: format!(
322                    "Performance {:.1}% below minimum {:.1}%",
323                    score.components.performance * 100.0,
324                    thresholds.performance * 100.0
325                ),
326            });
327        }
328        
329        if score.components.maintainability < thresholds.maintainability {
330            violations.push(Violation {
331                violation_type: ViolationType::Maintainability,
332                actual: score.components.maintainability,
333                required: thresholds.maintainability,
334                severity: Severity::High,
335                message: format!(
336                    "Maintainability {:.1}% below minimum {:.1}%",
337                    score.components.maintainability * 100.0,
338                    thresholds.maintainability * 100.0
339                ),
340            });
341        }
342        
343        if score.components.safety < thresholds.safety {
344            violations.push(Violation {
345                violation_type: ViolationType::Safety,
346                actual: score.components.safety,
347                required: thresholds.safety,
348                severity: Severity::Critical,
349                message: format!(
350                    "Safety {:.1}% below minimum {:.1}%",
351                    score.components.safety * 100.0,
352                    thresholds.safety * 100.0
353                ),
354            });
355        }
356        
357        if score.components.idiomaticity < thresholds.idiomaticity {
358            violations.push(Violation {
359                violation_type: ViolationType::Idiomaticity,
360                actual: score.components.idiomaticity,
361                required: thresholds.idiomaticity,
362                severity: Severity::Medium,
363                message: format!(
364                    "Idiomaticity {:.1}% below minimum {:.1}%",
365                    score.components.idiomaticity * 100.0,
366                    thresholds.idiomaticity * 100.0
367                ),
368            });
369        }
370    }
371    
372    fn check_anti_gaming_rules(
373        &self,
374        score: &QualityScore,
375        file_path: Option<&PathBuf>,
376        gaming_warnings: &mut Vec<String>,
377        violations: &mut Vec<Violation>,
378    ) {
379        // Check cache hit rate (prevent stale analysis gaming)
380        if score.cache_hit_rate > self.config.anti_gaming.max_cache_hit_rate {
381            gaming_warnings.push(format!(
382                "High cache hit rate {:.1}% may indicate stale analysis",
383                score.cache_hit_rate * 100.0
384            ));
385        }
386        
387        // Check file size requirements
388        if let Some(path) = file_path {
389            if let Ok(metadata) = std::fs::metadata(path) {
390                if metadata.len() < self.config.anti_gaming.min_file_size_bytes as u64 {
391                    gaming_warnings.push(format!(
392                        "File {} is very small ({} bytes) - may indicate gaming by splitting",
393                        path.display(),
394                        metadata.len()
395                    ));
396                }
397            }
398            
399            // Check if critical files require deep analysis
400            let path_str = path.to_string_lossy();
401            if self.config.anti_gaming.require_deep_analysis.iter().any(|p| path_str.contains(p))
402                && score.confidence < 0.9 {
403                    violations.push(Violation {
404                        violation_type: ViolationType::Gaming,
405                        actual: score.confidence,
406                        required: 0.9,
407                        severity: Severity::Critical,
408                        message: format!(
409                            "Critical file {} requires deep analysis (confidence < 90%)",
410                            path.display()
411                        ),
412                    });
413                }
414        }
415    }
416    
417    /// Export results for CI/CD integration
418    pub fn export_ci_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
419        if self.config.ci_integration.json_output {
420            self.export_json_results(results, output_dir)?;
421        }
422        
423        if self.config.ci_integration.junit_xml {
424            self.export_junit_results(results, output_dir)?;
425        }
426        
427        Ok(())
428    }
429    
430    fn export_json_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
431        let output_path = output_dir.join("quality-gates.json");
432        let json_content = serde_json::to_string_pretty(results)?;
433        std::fs::write(output_path, json_content)?;
434        Ok(())
435    }
436    
437    fn export_junit_results(&self, results: &[GateResult], output_dir: &Path) -> anyhow::Result<()> {
438        let output_path = output_dir.join("quality-gates.xml");
439        
440        let total = results.len();
441        let failures = results.iter().filter(|r| !r.passed).count();
442        
443        let mut xml = format!(r#"<?xml version="1.0" encoding="UTF-8"?>
444<testsuite name="Quality Gates" tests="{total}" failures="{failures}" time="0.0">
445"#);
446        
447        for (i, result) in results.iter().enumerate() {
448            let test_name = format!("quality-gate-{i}");
449            if result.passed {
450                xml.push_str(&format!(
451                    r#"  <testcase name="{test_name}" classname="QualityGate" time="0.0"/>
452"#
453                ));
454            } else {
455                xml.push_str(&format!(
456                    r#"  <testcase name="{}" classname="QualityGate" time="0.0">
457    <failure message="Quality gate violation">Score: {:.1}%, Grade: {}</failure>
458  </testcase>
459"#,
460                    test_name,
461                    result.score * 100.0,
462                    result.grade
463                ));
464            }
465        }
466        
467        xml.push_str("</testsuite>\n");
468        std::fs::write(output_path, xml)?;
469        Ok(())
470    }
471}
472
473impl PartialOrd for Grade {
474    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
475        Some(self.cmp(other))
476    }
477}
478
479impl Ord for Grade {
480    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
481        // use std::cmp::Ordering;
482        use Grade::{F, D, CMinus, C, CPlus, BMinus, B, BPlus, AMinus, A, APlus};
483        
484        let self_rank = match self {
485            F => 0,
486            D => 1,
487            CMinus => 2,
488            C => 3,
489            CPlus => 4,
490            BMinus => 5,
491            B => 6,
492            BPlus => 7,
493            AMinus => 8,
494            A => 9,
495            APlus => 10,
496        };
497        
498        let other_rank = match other {
499            F => 0,
500            D => 1,
501            CMinus => 2,
502            C => 3,
503            CPlus => 4,
504            BMinus => 5,
505            B => 6,
506            BPlus => 7,
507            AMinus => 8,
508            A => 9,
509            APlus => 10,
510        };
511        
512        self_rank.cmp(&other_rank)
513    }
514}
515
516// PartialEq and Eq are now derived in scoring.rs
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::quality::scoring::{QualityScore, Grade};
522    use tempfile::TempDir;
523
524    fn create_minimal_score() -> QualityScore {
525        use crate::quality::scoring::ScoreComponents;
526        QualityScore {
527            value: 0.5,
528            components: ScoreComponents {
529                correctness: 0.5,
530                performance: 0.5,
531                maintainability: 0.5,
532                safety: 0.5,
533                idiomaticity: 0.5,
534            },
535            grade: Grade::D,
536            confidence: 0.4,
537            cache_hit_rate: 0.3,
538        }
539    }
540
541    fn create_passing_score() -> QualityScore {
542        use crate::quality::scoring::ScoreComponents;
543        QualityScore {
544            value: 0.85,
545            components: ScoreComponents {
546                correctness: 0.9,
547                performance: 0.8,
548                maintainability: 0.8,
549                safety: 0.9,
550                idiomaticity: 0.7,
551            },
552            grade: Grade::APlus,
553            confidence: 0.9,
554            cache_hit_rate: 0.2,
555        }
556    }
557
558    // Test 1: Default Configuration Creation
559    #[test]
560    fn test_default_quality_gate_config() {
561        let config = QualityGateConfig::default();
562        
563        assert_eq!(config.min_score, 0.7);
564        assert_eq!(config.min_grade, Grade::BMinus);
565        assert_eq!(config.component_thresholds.correctness, 0.8);
566        assert_eq!(config.component_thresholds.safety, 0.8);
567        assert_eq!(config.anti_gaming.min_confidence, 0.6);
568        assert!(config.ci_integration.fail_on_violation);
569        assert!(config.project_overrides.is_empty());
570    }
571
572    // Test 2: Quality Gate Enforcer Creation
573    #[test]
574    fn test_quality_gate_enforcer_creation() {
575        let config = QualityGateConfig::default();
576        let enforcer = QualityGateEnforcer::new(config);
577        
578        // Verify enforcer uses the provided config
579        let score = create_minimal_score();
580        let result = enforcer.enforce_gates(&score, None);
581        
582        // Should fail with default thresholds
583        assert!(!result.passed);
584        assert!(!result.violations.is_empty());
585    }
586
587    // Test 3: Passing Quality Gate - All Criteria Met
588    #[test]
589    fn test_quality_gate_passes_with_high_score() {
590        let config = QualityGateConfig::default();
591        let enforcer = QualityGateEnforcer::new(config);
592        let score = create_passing_score();
593        
594        let result = enforcer.enforce_gates(&score, None);
595        
596        assert!(result.passed, "High quality score should pass all gates");
597        assert_eq!(result.score, 0.85);
598        assert_eq!(result.grade, Grade::APlus);
599        assert!(result.violations.is_empty());
600        assert_eq!(result.confidence, 0.9);
601        assert!(result.gaming_warnings.is_empty());
602    }
603
604    // Test 4: Failing Overall Score Threshold
605    #[test]
606    fn test_quality_gate_fails_overall_score() {
607        let config = QualityGateConfig::default(); // min_score: 0.7
608        let enforcer = QualityGateEnforcer::new(config);
609        
610        let mut score = create_minimal_score();
611        score.value = 0.6; // Below 0.7 threshold
612        
613        let result = enforcer.enforce_gates(&score, None);
614        
615        assert!(!result.passed, "Score below threshold should fail");
616        
617        // Should have overall score violation
618        let overall_violations: Vec<_> = result.violations.iter()
619            .filter(|v| v.violation_type == ViolationType::OverallScore)
620            .collect();
621        assert_eq!(overall_violations.len(), 1);
622        
623        let violation = &overall_violations[0];
624        assert_eq!(violation.actual, 0.6);
625        assert_eq!(violation.required, 0.7);
626        assert_eq!(violation.severity, Severity::Critical);
627        assert!(violation.message.contains("60.0%"));
628        assert!(violation.message.contains("70.0%"));
629    }
630
631    // Test 5: Confidence Threshold Violation
632    #[test]
633    fn test_confidence_threshold_violation() {
634        let config = QualityGateConfig::default(); // min_confidence: 0.6
635        let enforcer = QualityGateEnforcer::new(config);
636        
637        let mut score = create_passing_score();
638        score.confidence = 0.4; // Below 0.6 threshold
639        
640        let result = enforcer.enforce_gates(&score, None);
641        
642        let confidence_violations: Vec<_> = result.violations.iter()
643            .filter(|v| v.violation_type == ViolationType::Confidence)
644            .collect();
645        assert_eq!(confidence_violations.len(), 1);
646        
647        let violation = &confidence_violations[0];
648        assert_eq!(violation.severity, Severity::High);
649        assert_eq!(violation.actual, 0.4);
650        assert_eq!(violation.required, 0.6);
651    }
652
653    // Test 6: Configuration File Loading (Success)
654    #[test]
655    fn test_load_config_creates_default() {
656        let temp_dir = TempDir::new().unwrap();
657        let project_root = temp_dir.path();
658        
659        let config = QualityGateEnforcer::load_config(project_root).unwrap();
660        
661        // Should create default config
662        assert_eq!(config.min_score, 0.7);
663        assert_eq!(config.min_grade, Grade::BMinus);
664        
665        // Should create .ruchy/score.toml file
666        let config_path = project_root.join(".ruchy").join("score.toml");
667        assert!(config_path.exists(), "Config file should be created");
668        
669        // File should contain valid TOML
670        let content = std::fs::read_to_string(config_path).unwrap();
671        assert!(content.contains("min_score"));
672        assert!(content.contains("0.7"));
673    }
674
675    // Test 7: Serialization/Deserialization
676    #[test]
677    fn test_config_serialization() {
678        let original_config = QualityGateConfig::default();
679        
680        // Serialize to TOML
681        let toml_content = toml::to_string(&original_config).unwrap();
682        assert!(toml_content.contains("min_score"));
683        
684        // Deserialize back
685        let deserialized_config: QualityGateConfig = toml::from_str(&toml_content).unwrap();
686        assert_eq!(deserialized_config.min_score, original_config.min_score);
687        assert_eq!(deserialized_config.min_grade, original_config.min_grade);
688    }
689}