Skip to main content

aivcs_core/
quality_guardrails.rs

1//! Code quality guardrails for release/promotion decisions.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::domain::{AivcsError, Result};
10use oxidized_state::storage_traits::ContentDigest;
11
12/// Required quality checks.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum QualityCheck {
16    Fmt,
17    Lint,
18    Test,
19    Verification,
20}
21
22/// Finding severity.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum QualitySeverity {
26    Info,
27    Low,
28    Medium,
29    High,
30    Critical,
31}
32
33/// Actionable finding produced by a quality check.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CheckFinding {
36    pub severity: QualitySeverity,
37    pub message: String,
38    pub file_path: Option<String>,
39    pub line: Option<u32>,
40}
41
42/// Result of one quality check.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct CheckResult {
45    pub check: QualityCheck,
46    pub passed: bool,
47    pub findings: Vec<CheckFinding>,
48}
49
50/// Release action guarded by quality policy.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ReleaseAction {
54    Promote,
55    Publish,
56}
57
58impl ReleaseAction {
59    fn is_high_risk(self) -> bool {
60        matches!(self, Self::Publish)
61    }
62}
63
64/// Guardrail profile (`standard` or `strict`).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct GuardrailPolicyProfile {
67    pub name: &'static str,
68    pub required_checks: Vec<QualityCheck>,
69    pub block_on_severity: QualitySeverity,
70}
71
72impl GuardrailPolicyProfile {
73    pub fn standard() -> Self {
74        Self {
75            name: "standard",
76            required_checks: vec![QualityCheck::Fmt, QualityCheck::Lint, QualityCheck::Test],
77            block_on_severity: QualitySeverity::High,
78        }
79    }
80
81    pub fn strict() -> Self {
82        Self {
83            name: "strict",
84            required_checks: vec![
85                QualityCheck::Fmt,
86                QualityCheck::Lint,
87                QualityCheck::Test,
88                QualityCheck::Verification,
89            ],
90            block_on_severity: QualitySeverity::Medium,
91        }
92    }
93}
94
95/// Coverage metrics for required checks.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct GuardrailCoverage {
98    pub required_checks: usize,
99    pub executed_required_checks: usize,
100    pub passed_required_checks: usize,
101}
102
103/// Outcome of guardrail evaluation.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct GuardrailVerdict {
106    pub passed: bool,
107    pub blocked_checks: Vec<QualityCheck>,
108    pub missing_required_checks: Vec<QualityCheck>,
109    pub blocking_findings: Vec<CheckFinding>,
110    pub requires_approval: bool,
111    pub coverage: GuardrailCoverage,
112    pub evaluated_at: DateTime<Utc>,
113}
114
115/// Auditable run artifact for guardrail outcomes.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct GuardrailArtifact {
118    pub run_id: String,
119    pub profile_name: String,
120    pub check_results: Vec<CheckResult>,
121    pub verdict: GuardrailVerdict,
122}
123
124/// Evaluate quality checks against a policy profile.
125pub fn evaluate_quality_guardrails(
126    profile: &GuardrailPolicyProfile,
127    results: &[CheckResult],
128    action: ReleaseAction,
129    explicit_approval: bool,
130) -> GuardrailVerdict {
131    let mut by_check: HashMap<QualityCheck, &CheckResult> = HashMap::new();
132    for r in results {
133        by_check.insert(r.check, r);
134    }
135
136    let mut blocked_checks = Vec::new();
137    let mut missing_required_checks = Vec::new();
138    let mut blocking_findings = Vec::new();
139    let mut executed_required_checks = 0usize;
140    let mut passed_required_checks = 0usize;
141
142    for required in &profile.required_checks {
143        match by_check.get(required) {
144            None => missing_required_checks.push(*required),
145            Some(result) => {
146                executed_required_checks += 1;
147                if result.passed {
148                    passed_required_checks += 1;
149                } else {
150                    blocked_checks.push(*required);
151                }
152
153                for f in &result.findings {
154                    if f.severity >= profile.block_on_severity {
155                        blocking_findings.push(f.clone());
156                    }
157                }
158            }
159        }
160    }
161
162    // Deduplicate blocked checks while preserving sort stability for determinism.
163    let mut seen = HashSet::new();
164    blocked_checks.retain(|c| seen.insert(*c));
165
166    let requires_approval = action.is_high_risk() && !explicit_approval;
167    let passed = blocked_checks.is_empty()
168        && missing_required_checks.is_empty()
169        && blocking_findings.is_empty()
170        && !requires_approval;
171
172    GuardrailVerdict {
173        passed,
174        blocked_checks,
175        missing_required_checks,
176        blocking_findings,
177        requires_approval,
178        coverage: GuardrailCoverage {
179            required_checks: profile.required_checks.len(),
180            executed_required_checks,
181            passed_required_checks,
182        },
183        evaluated_at: Utc::now(),
184    }
185}
186
187/// Human-readable release block reason, if blocked.
188pub fn release_block_reason(verdict: &GuardrailVerdict) -> Option<String> {
189    if verdict.passed {
190        return None;
191    }
192    if verdict.requires_approval {
193        return Some("high-risk action requires explicit approval".to_string());
194    }
195    if !verdict.missing_required_checks.is_empty() {
196        return Some("required checks missing".to_string());
197    }
198    if !verdict.blocked_checks.is_empty() {
199        return Some("required checks failed".to_string());
200    }
201    if !verdict.blocking_findings.is_empty() {
202        return Some("blocking findings present".to_string());
203    }
204    Some("quality guardrail blocked".to_string())
205}
206
207/// Persist `<dir>/<run_id>/guardrails.json` and `<dir>/<run_id>/guardrails.digest`.
208pub fn write_guardrail_artifact(artifact: &GuardrailArtifact, dir: &Path) -> Result<PathBuf> {
209    let run_dir = dir.join(&artifact.run_id);
210    std::fs::create_dir_all(&run_dir)?;
211
212    let path = run_dir.join("guardrails.json");
213    let digest_path = run_dir.join("guardrails.digest");
214    let json = serde_json::to_vec_pretty(artifact)?;
215    let digest = ContentDigest::from_bytes(&json).as_str().to_string();
216
217    std::fs::write(&path, &json)?;
218    std::fs::write(&digest_path, digest.as_bytes())?;
219
220    Ok(path)
221}
222
223/// Read and verify `<dir>/<run_id>/guardrails.json` integrity.
224pub fn read_guardrail_artifact(run_id: &str, dir: &Path) -> Result<GuardrailArtifact> {
225    let run_dir = dir.join(run_id);
226    let path = run_dir.join("guardrails.json");
227    let digest_path = run_dir.join("guardrails.digest");
228
229    let json = std::fs::read(&path)?;
230    let digest = std::fs::read_to_string(&digest_path)?;
231    let actual = ContentDigest::from_bytes(&json).as_str().to_string();
232    if digest.trim() != actual {
233        return Err(AivcsError::DigestMismatch {
234            expected: digest.trim().to_string(),
235            actual,
236        });
237    }
238    let artifact: GuardrailArtifact = serde_json::from_slice(&json)?;
239    Ok(artifact)
240}