use anyhow::{Context, Result};
use chrono::Utc;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use super::evaluation::{self, QualityDecision, QualityJudgeRun, StageEvaluation};
use super::providers::{load_sdd_config, QualityConfig, QualityMode, QualityProfile};
#[derive(Clone, Debug)]
pub struct CandidateArtifact {
pub path: PathBuf,
pub hash: String,
pub run_id: String,
pub attempt: u8,
}
#[derive(Clone, Debug)]
pub struct QualityPipelineResult {
pub candidate: CandidateArtifact,
pub evaluation: StageEvaluation,
pub promoted: bool,
}
#[allow(dead_code)] #[derive(Clone, Debug, Serialize)]
pub struct JudgeInput {
pub artifact: String,
pub rubric: Vec<String>,
pub evidence_manifest: BTreeSet<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JudgeFinding {
pub severity: String,
pub message: String,
pub evidence_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JudgeVerdict {
pub decision: QualityDecision,
#[serde(default)]
pub findings: Vec<JudgeFinding>,
}
#[allow(dead_code)] pub trait QualityJudge {
fn judge(&self, input: &JudgeInput) -> Result<JudgeVerdict>;
}
#[allow(dead_code)] pub fn parse_judge_verdict(
raw: &str,
evidence_manifest: &BTreeSet<String>,
) -> Result<JudgeVerdict> {
let verdict: JudgeVerdict =
serde_json::from_str(raw).context("judge deve devolver JSON válido")?;
for finding in &verdict.findings {
for evidence_id in &finding.evidence_ids {
if !evidence_manifest.contains(evidence_id) {
anyhow::bail!("judge referenciou evidência inexistente: {evidence_id}");
}
}
}
Ok(verdict)
}
pub fn effective_profile(config: &QualityConfig, risk: Option<&str>) -> QualityProfile {
match config.profile {
QualityProfile::Auto => match risk.unwrap_or("medium") {
"high" | "critical" => QualityProfile::Critical,
"low" => QualityProfile::Fast,
_ => QualityProfile::Standard,
},
ref profile => profile.clone(),
}
}
pub fn apply_judge_consensus(
evaluation: &mut StageEvaluation,
profile: QualityProfile,
verdicts: &[JudgeVerdict],
) {
evaluation.profile = profile.clone();
evaluation.judge_runs = verdicts
.iter()
.map(|verdict| QualityJudgeRun {
status: "completed".to_string(),
provider: None,
model: None,
decision: Some(verdict.decision.clone()),
})
.collect();
if evaluation.decision == QualityDecision::Block {
return;
}
evaluation.decision = match profile {
QualityProfile::Fast => QualityDecision::Allow,
QualityProfile::Standard => match verdicts.first() {
Some(verdict) => verdict.decision.clone(),
None => QualityDecision::Review,
},
QualityProfile::Critical => {
if verdicts.len() < 2 {
QualityDecision::Review
} else if verdicts
.iter()
.all(|verdict| verdict.decision == QualityDecision::Allow)
{
QualityDecision::Allow
} else if verdicts
.iter()
.any(|verdict| verdict.decision == QualityDecision::Block)
{
QualityDecision::Block
} else {
QualityDecision::Review
}
}
QualityProfile::Auto => QualityDecision::Review,
};
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)] pub enum RepairStopReason {
ExhaustedAttempts,
RepeatedCritical,
ScoreDidNotImprove,
}
#[allow(dead_code)] pub fn repair_stop_reason(
config: &QualityConfig,
profile: QualityProfile,
attempt: u8,
previous: &StageEvaluation,
current: &StageEvaluation,
) -> Option<RepairStopReason> {
let max_attempts = match profile {
QualityProfile::Critical => config.attempts.critical,
QualityProfile::Standard | QualityProfile::Auto => config.attempts.standard,
QualityProfile::Fast => 0,
};
if attempt >= max_attempts {
return Some(RepairStopReason::ExhaustedAttempts);
}
let prior_critical = previous
.findings
.iter()
.filter(|finding| finding.severity == super::evaluation::EvaluationSeverity::Critical)
.map(|finding| finding.check.as_str())
.collect::<BTreeSet<_>>();
let repeated_critical = current.findings.iter().any(|finding| {
finding.severity == super::evaluation::EvaluationSeverity::Critical
&& prior_critical.contains(finding.check.as_str())
});
if repeated_critical {
return Some(RepairStopReason::RepeatedCritical);
}
if score_value(¤t.scores) <= score_value(&previous.scores) {
return Some(RepairStopReason::ScoreDidNotImprove);
}
None
}
#[allow(dead_code)] fn score_value(scores: &super::evaluation::QualityScores) -> f64 {
scores.semantic.unwrap_or(scores.deterministic)
}
pub struct QualityPipeline<'a> {
root: &'a Path,
}
impl<'a> QualityPipeline<'a> {
pub fn new(root: &'a Path) -> Self {
Self { root }
}
pub fn submit(
&self,
name: &str,
stage: &str,
content: &str,
attempt: u8,
) -> Result<QualityPipelineResult> {
let candidate = self.write_candidate(name, stage, content, attempt)?;
let mut evaluation =
evaluation::evaluate_stage_at_path(self.root, name, stage, &candidate.path)?;
evaluation.candidate_hash = Some(candidate.hash.clone());
evaluation.attempts = candidate.attempt;
evaluation
.source_hashes
.insert("candidate".to_string(), candidate.hash.clone());
evaluation
.source_hashes
.insert("candidate_run".to_string(), candidate.run_id.clone());
let rubric = crate::contract::artifact_quality_rubric(stage).join("\n");
evaluation.rubric_hash = Some(sha256(&rubric));
let context_pack = self
.root
.join(".sdd/intelligence/context-packs")
.join(crate::artifact_slug(name))
.join(format!("{stage}.md"));
if let Ok(context) = fs::read_to_string(&context_pack) {
let hash = sha256(&context);
evaluation.context_pack_hash = Some(hash.clone());
evaluation
.source_hashes
.insert("context_pack".to_string(), hash);
}
let quality = load_sdd_config(self.root)?.quality;
let profile = effective_profile(&quality, self.risk_level(name).as_deref());
apply_judge_consensus(&mut evaluation, profile, &[]);
let promoted =
quality.mode == QualityMode::Shadow || evaluation.decision == QualityDecision::Allow;
Ok(QualityPipelineResult {
candidate,
evaluation,
promoted,
})
}
fn write_candidate(
&self,
name: &str,
stage: &str,
content: &str,
attempt: u8,
) -> Result<CandidateArtifact> {
let slug = crate::artifact_slug(name);
let run_id = Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
let path = self
.root
.join(".sdd/quality/candidates")
.join(slug)
.join(stage)
.join(&run_id)
.join(format!("attempt-{attempt}.md"));
let redacted = redact_sensitive(content)?;
crate::domain::orchestrator::write_atomic(&path, redacted.as_bytes())?;
Ok(CandidateArtifact {
path,
hash: sha256(content),
run_id,
attempt,
})
}
fn risk_level(&self, name: &str) -> Option<String> {
let path = self
.root
.join("docs")
.join(crate::artifact_slug(name))
.join("00-risk-classification.md");
let text = fs::read_to_string(path).ok()?.to_lowercase();
["critical", "high", "medium", "low"]
.into_iter()
.find(|level| text.contains(level))
.map(str::to_string)
}
}
fn redact_sensitive(content: &str) -> Result<String> {
let secret = Regex::new(
r"(?i)(?:sk-[a-z0-9_-]{8,}|(?:api[_-]?key|token|password|secret)\s*[:=]\s*)[^\s`]+",
)?;
Ok(secret.replace_all(content, "[REDACTED]").into_owned())
}
fn sha256(content: &str) -> String {
let mut hash = Sha256::new();
hash.update(content.as_bytes());
format!("{:x}", hash.finalize())
}
#[allow(dead_code)] pub fn candidate_paths(root: &Path, name: &str, stage: &str) -> Result<Vec<PathBuf>> {
let dir = root
.join(".sdd/quality/candidates")
.join(crate::artifact_slug(name))
.join(stage);
if !dir.exists() {
return Ok(Vec::new());
}
let mut paths = Vec::new();
for run in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
let run = run?;
let path = run.path();
if path.is_dir() {
paths.extend(
fs::read_dir(path)?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().is_some_and(|ext| ext == "md")),
);
}
}
paths.sort();
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redaction_removes_secret_values_before_persistence() {
let redacted = redact_sensitive("token=top-secret-value sk-abcdefghijklmnop").unwrap();
assert!(!redacted.contains("top-secret-value"));
assert!(!redacted.contains("sk-abcdefghijklmnop"));
assert!(redacted.contains("[REDACTED]"));
}
#[test]
fn critical_profile_requires_two_independent_allows() {
let mut evaluation = StageEvaluation {
schema_version: 2,
orchestration: "test".to_string(),
slug: "test".to_string(),
stage: "prd".to_string(),
artifact_type: Some("prd".to_string()),
artifact_path: "candidate.md".to_string(),
traceability_map: "traceability-map.yaml".to_string(),
status: "pass".to_string(),
issues: Vec::new(),
profile: QualityProfile::Auto,
mode: QualityMode::Enforce,
scores: super::evaluation::QualityScores {
deterministic: 1.0,
semantic: None,
requirement_coverage: 1.0,
evidence_coverage: 1.0,
},
findings: Vec::new(),
evidence_coverage: 1.0,
judge_runs: Vec::new(),
attempts: 1,
decision: QualityDecision::Allow,
override_record: None,
candidate_hash: None,
context_pack_hash: None,
rubric_hash: None,
source_hashes: Default::default(),
confidence: super::evaluation::QualityConfidence::Medium,
};
let allow = JudgeVerdict {
decision: QualityDecision::Allow,
findings: Vec::new(),
};
apply_judge_consensus(
&mut evaluation,
QualityProfile::Critical,
std::slice::from_ref(&allow),
);
assert_eq!(evaluation.decision, QualityDecision::Review);
apply_judge_consensus(
&mut evaluation,
QualityProfile::Critical,
&[allow.clone(), allow],
);
assert_eq!(evaluation.decision, QualityDecision::Allow);
}
#[test]
fn auto_profile_tracks_risk_level_conservatively() {
let config = QualityConfig::default();
assert_eq!(
effective_profile(&config, Some("low")),
QualityProfile::Fast
);
assert_eq!(
effective_profile(&config, Some("high")),
QualityProfile::Critical
);
assert_eq!(
effective_profile(&config, None),
QualityProfile::Standard,
"risco ausente não reduz o rigor",
);
}
#[test]
fn repair_stops_when_a_critical_finding_repeats() {
let mut previous = sample_evaluation();
previous.findings.push(super::evaluation::EvaluationIssue {
severity: super::evaluation::EvaluationSeverity::Critical,
check: "placeholders".to_string(),
message: "placeholder".to_string(),
});
let current = previous.clone();
assert_eq!(
repair_stop_reason(
&QualityConfig::default(),
QualityProfile::Standard,
1,
&previous,
¤t,
),
Some(RepairStopReason::RepeatedCritical),
);
}
fn sample_evaluation() -> StageEvaluation {
StageEvaluation {
schema_version: 2,
orchestration: "test".to_string(),
slug: "test".to_string(),
stage: "prd".to_string(),
artifact_type: Some("prd".to_string()),
artifact_path: "candidate.md".to_string(),
traceability_map: "traceability-map.yaml".to_string(),
status: "pass".to_string(),
issues: Vec::new(),
profile: QualityProfile::Auto,
mode: QualityMode::Enforce,
scores: super::evaluation::QualityScores {
deterministic: 1.0,
semantic: None,
requirement_coverage: 1.0,
evidence_coverage: 1.0,
},
findings: Vec::new(),
evidence_coverage: 1.0,
judge_runs: Vec::new(),
attempts: 1,
decision: QualityDecision::Allow,
override_record: None,
candidate_hash: None,
context_pack_hash: None,
rubric_hash: None,
source_hashes: Default::default(),
confidence: super::evaluation::QualityConfidence::Medium,
}
}
}