use anyhow::{anyhow, Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::contract;
use crate::domain::providers::{load_sdd_config, QualityConfig, QualityMode, QualityProfile};
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EvaluationSeverity {
Warning,
Critical,
}
#[derive(Clone, Debug, Serialize)]
pub struct EvaluationIssue {
pub severity: EvaluationSeverity,
pub check: String,
pub message: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityScores {
pub deterministic: f64,
pub semantic: Option<f64>,
pub requirement_coverage: f64,
pub evidence_coverage: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum QualityDecision {
Allow,
Review,
Block,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum QualityConfidence {
Low,
Medium,
High,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityJudgeRun {
pub status: String,
pub provider: Option<String>,
pub model: Option<String>,
pub decision: Option<QualityDecision>,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityOverride {
pub author: String,
pub reason: String,
pub evidence: String,
pub recorded_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct StageEvaluation {
pub schema_version: u8,
pub orchestration: String,
pub slug: String,
pub stage: String,
pub artifact_type: Option<String>,
pub artifact_path: String,
pub traceability_map: String,
pub status: String,
pub issues: Vec<EvaluationIssue>,
pub profile: QualityProfile,
pub mode: QualityMode,
pub scores: QualityScores,
pub findings: Vec<EvaluationIssue>,
pub evidence_coverage: f64,
pub judge_runs: Vec<QualityJudgeRun>,
pub attempts: u8,
pub decision: QualityDecision,
pub override_record: Option<QualityOverride>,
pub candidate_hash: Option<String>,
pub context_pack_hash: Option<String>,
pub rubric_hash: Option<String>,
pub source_hashes: BTreeMap<String, String>,
pub confidence: QualityConfidence,
}
impl StageEvaluation {
pub fn has_critical(&self) -> bool {
self.issues
.iter()
.any(|issue| issue.severity == EvaluationSeverity::Critical)
}
pub fn should_block(&self) -> bool {
self.has_critical()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct OrchestrationEvaluation {
pub orchestration: String,
pub slug: String,
pub status: String,
pub stages: Vec<StageEvaluation>,
pub issue_count: usize,
pub critical_count: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityReport {
pub schema_version: u8,
pub orchestration: String,
pub slug: String,
pub status: String,
pub evaluation: OrchestrationEvaluation,
pub tools: Vec<QualityToolStatus>,
pub recommendations: Vec<String>,
pub profile: QualityProfile,
pub mode: QualityMode,
pub scores: QualityScores,
pub findings: Vec<EvaluationIssue>,
pub evidence_coverage: f64,
pub judge_runs: Vec<QualityJudgeRun>,
pub attempts: u8,
pub decision: QualityDecision,
pub override_record: Option<QualityOverride>,
pub candidate_hash: Option<String>,
pub context_pack_hash: Option<String>,
pub rubric_hash: Option<String>,
pub source_hashes: BTreeMap<String, String>,
pub confidence: QualityConfidence,
pub eval_harness_pass_rate: Option<f64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct QualityToolStatus {
pub id: String,
pub available: bool,
pub required: bool,
pub command: String,
pub fallback: String,
}
pub fn evaluate_stage(root: &Path, name: &str, stage_key: &str) -> Result<StageEvaluation> {
evaluate_stage_with_quality(root, name, stage_key, load_sdd_config(root)?.quality)
}
pub fn evaluate_stage_with_quality(
root: &Path,
name: &str,
stage_key: &str,
quality: QualityConfig,
) -> Result<StageEvaluation> {
let artifact_dir = crate::locate_artifact_dir(root, name);
let stage = contract::stage_by_key(stage_key)
.ok_or_else(|| anyhow!("unknown stage for evaluation: {stage_key}"))?;
let artifact_path = artifact_dir.join(&stage.filename);
evaluate_stage_at_path_with_quality(root, name, stage_key, &artifact_path, quality)
}
pub fn evaluate_stage_at_path(
root: &Path,
name: &str,
stage_key: &str,
artifact_path: &Path,
) -> Result<StageEvaluation> {
evaluate_stage_at_path_with_quality(
root,
name,
stage_key,
artifact_path,
load_sdd_config(root)?.quality,
)
}
pub fn evaluate_stage_at_path_with_quality(
root: &Path,
name: &str,
stage_key: &str,
artifact_path: &Path,
quality: QualityConfig,
) -> Result<StageEvaluation> {
let slug = crate::artifact_slug(name);
let artifact_dir = crate::locate_artifact_dir(root, name);
let traceability_map = artifact_dir.join("traceability-map.yaml");
let stage = contract::stage_by_key(stage_key)
.ok_or_else(|| anyhow!("unknown stage for evaluation: {stage_key}"))?;
let artifact_type = stage.artifact_type.clone();
let artifact_path = artifact_path.to_path_buf();
let mut issues = Vec::new();
let map_value = read_yaml(&traceability_map);
if map_value.is_none() {
issues.push(issue(
EvaluationSeverity::Critical,
"traceability-map",
format!("traceability-map ausente em {}", traceability_map.display()),
));
}
check_traceability_state(
&mut issues,
map_value.as_ref(),
&stage.key,
&stage.filename,
artifact_path.exists(),
);
if !artifact_path.exists() {
issues.push(issue(
EvaluationSeverity::Critical,
"artifact-file",
format!("artefato ausente: {}", artifact_path.display()),
));
return Ok(finalize_stage_report(
name,
slug,
stage_key,
artifact_type,
artifact_path,
traceability_map,
issues,
quality,
));
}
let markdown = fs::read_to_string(&artifact_path)
.with_context(|| format!("reading artifact {}", artifact_path.display()))?;
if let Some(artifact_type) = artifact_type.as_deref() {
validate_required_sections(&mut issues, artifact_type, &markdown)?;
validate_semantics(&mut issues, artifact_type, &markdown)?;
}
validate_context_pack_freshness(&mut issues, root, &slug, stage_key, &artifact_path);
Ok(finalize_stage_report(
name,
slug,
stage_key,
artifact_type,
artifact_path,
traceability_map,
issues,
quality,
))
}
#[allow(dead_code)] pub fn evaluate_orchestration(root: &Path, name: &str) -> Result<OrchestrationEvaluation> {
evaluate_orchestration_with_quality(root, name, load_sdd_config(root)?.quality)
}
pub fn evaluate_orchestration_with_quality(
root: &Path,
name: &str,
quality: QualityConfig,
) -> Result<OrchestrationEvaluation> {
let slug = crate::artifact_slug(name);
let artifact_dir = crate::locate_artifact_dir(root, name);
let traceability_map = artifact_dir.join("traceability-map.yaml");
let mut stages = Vec::new();
if !traceability_map.exists() {
let stage = contract::stage_by_key("idea").ok_or_else(|| anyhow!("missing idea stage"))?;
let missing_issues = vec![issue(
EvaluationSeverity::Critical,
"traceability-map",
format!("traceability-map ausente em {}", traceability_map.display()),
)];
let missing = finalize_stage_report(
name,
slug.clone(),
"idea",
stage.artifact_type.clone(),
artifact_dir.join(&stage.filename),
traceability_map,
missing_issues,
quality.clone(),
);
return Ok(orchestration_report(name, slug, vec![missing]));
}
let value = read_yaml(&traceability_map);
for stage in contract::stages() {
let file_exists = artifact_dir.join(&stage.filename).exists();
let state = artifact_state(value.as_ref(), &stage.key);
let should_eval = file_exists
|| matches!(
state.as_deref(),
Some("approved" | "recorded" | "draft" | "in_progress")
);
if should_eval {
stages.push(evaluate_stage_with_quality(
root,
name,
&stage.key,
quality.clone(),
)?);
}
}
Ok(orchestration_report(name, slug, stages))
}
pub fn quality_report(root: &Path, name: &str) -> Result<QualityReport> {
let quality = load_sdd_config(root)?.quality;
quality_report_with_config(root, name, quality)
}
pub fn quality_report_with_config(
root: &Path,
name: &str,
quality: QualityConfig,
) -> Result<QualityReport> {
let evaluation = evaluate_orchestration_with_quality(root, name, quality.clone())?;
let tools = quality_tools(root);
let mut recommendations = Vec::new();
if evaluation.critical_count > 0 {
recommendations
.push("Corrigir avaliações críticas antes de avançar o motor autônomo.".to_string());
}
for tool in &tools {
if !tool.available && tool.required {
recommendations.push(format!(
"Instalar ou configurar `{}` para fortalecer o gate de qualidade.",
tool.command
));
}
}
if !root.join(".sdd/intelligence/learnings.jsonl").exists() {
recommendations.push(
"Rodar `sdd intelligence learn --all` para popular o índice derivado.".to_string(),
);
}
let status = if evaluation.critical_count == 0 {
"pass"
} else {
"fail"
};
let findings = evaluation
.stages
.iter()
.flat_map(|stage| stage.findings.clone())
.collect::<Vec<_>>();
let scores = aggregate_scores(&evaluation.stages);
let decision = aggregate_decision(&evaluation.stages);
let confidence = aggregate_confidence(&evaluation.stages);
Ok(QualityReport {
schema_version: 2,
orchestration: name.to_string(),
slug: evaluation.slug.clone(),
status: status.to_string(),
evaluation,
tools,
recommendations,
profile: quality.profile,
mode: quality.mode,
evidence_coverage: scores.evidence_coverage,
scores,
findings,
judge_runs: Vec::new(),
attempts: 0,
decision,
override_record: None,
candidate_hash: None,
context_pack_hash: None,
rubric_hash: None,
source_hashes: BTreeMap::new(),
confidence,
eval_harness_pass_rate: read_latest_eval_harness_pass_rate(root)
.ok()
.flatten()
.map(|(_, rate)| rate),
})
}
pub fn benchmark_markdown(stage_key: &str, markdown: &str) -> Result<StageEvaluation> {
let stage = contract::stage_by_key(stage_key)
.ok_or_else(|| anyhow!("unknown stage for benchmark: {stage_key}"))?;
let artifact_type = stage.artifact_type.clone();
let mut issues = Vec::new();
if let Some(artifact_type) = artifact_type.as_deref() {
validate_required_sections(&mut issues, artifact_type, markdown)?;
validate_semantics(&mut issues, artifact_type, markdown)?;
}
let quality = QualityConfig {
profile: QualityProfile::Fast,
mode: QualityMode::Enforce,
..QualityConfig::default()
};
Ok(finalize_stage_report(
"benchmark",
"benchmark".to_string(),
stage_key,
artifact_type,
PathBuf::from("benchmark-candidate.md"),
PathBuf::from("benchmark-traceability-map.yaml"),
issues,
quality,
))
}
pub fn evaluation_event(kind: &str, report: &impl Serialize) -> serde_json::Value {
json!({
"schema_version": 2,
"kind": kind,
"report": report,
})
}
fn orchestration_report(
name: &str,
slug: String,
stages: Vec<StageEvaluation>,
) -> OrchestrationEvaluation {
let issue_count = stages.iter().map(|stage| stage.issues.len()).sum();
let critical_count = stages
.iter()
.flat_map(|stage| &stage.issues)
.filter(|issue| issue.severity == EvaluationSeverity::Critical)
.count();
let status = if critical_count == 0 { "pass" } else { "fail" };
OrchestrationEvaluation {
orchestration: name.to_string(),
slug,
status: status.to_string(),
stages,
issue_count,
critical_count,
}
}
#[allow(clippy::too_many_arguments)]
fn finalize_stage_report(
name: &str,
slug: String,
stage: &str,
artifact_type: Option<String>,
artifact_path: PathBuf,
traceability_map: PathBuf,
issues: Vec<EvaluationIssue>,
quality: QualityConfig,
) -> StageEvaluation {
let issues = normalize_shadow_issues(stage, quality.mode.clone(), issues);
let has_critical = issues
.iter()
.any(|issue| issue.severity == EvaluationSeverity::Critical);
let status = if has_critical { "fail" } else { "pass" };
let scores = scores_for_issues(&issues);
let decision = decision_for(&quality, has_critical, scores.semantic);
StageEvaluation {
schema_version: 2,
orchestration: name.to_string(),
slug,
stage: stage.to_string(),
artifact_type,
artifact_path: artifact_path.display().to_string(),
traceability_map: traceability_map.display().to_string(),
status: status.to_string(),
findings: issues.clone(),
issues,
profile: quality.profile,
mode: quality.mode,
evidence_coverage: scores.evidence_coverage,
scores,
judge_runs: Vec::new(),
attempts: 0,
decision,
override_record: None,
candidate_hash: None,
context_pack_hash: None,
rubric_hash: None,
source_hashes: BTreeMap::new(),
confidence: if has_critical {
QualityConfidence::Low
} else {
QualityConfidence::Medium
},
}
}
fn normalize_shadow_issues(
stage: &str,
mode: QualityMode,
issues: Vec<EvaluationIssue>,
) -> Vec<EvaluationIssue> {
if mode != QualityMode::Shadow {
return issues;
}
let rubric = contract::artifact_quality_rubric(stage);
issues
.into_iter()
.map(|mut issue| {
if issue.severity == EvaluationSeverity::Critical
&& rubric.iter().any(|rule| *rule == issue.check)
{
issue.severity = EvaluationSeverity::Warning;
}
issue
})
.collect()
}
fn scores_for_issues(issues: &[EvaluationIssue]) -> QualityScores {
let critical_count = issues
.iter()
.filter(|issue| issue.severity == EvaluationSeverity::Critical)
.count();
let warning_count = issues
.iter()
.filter(|issue| issue.severity == EvaluationSeverity::Warning)
.count();
let deterministic = if critical_count > 0 {
0.0
} else {
(1.0 - (warning_count as f64 * 0.05)).max(0.0)
};
QualityScores {
deterministic,
semantic: None,
requirement_coverage: if critical_count == 0 { 1.0 } else { 0.0 },
evidence_coverage: if critical_count == 0 { 1.0 } else { 0.0 },
}
}
fn decision_for(
quality: &QualityConfig,
has_critical: bool,
semantic_score: Option<f64>,
) -> QualityDecision {
if has_critical {
return QualityDecision::Block;
}
let semantic_target = match quality.profile {
QualityProfile::Critical => quality.thresholds.semantic_critical,
QualityProfile::Standard => quality.thresholds.semantic_standard,
QualityProfile::Auto | QualityProfile::Fast => 0.0,
};
if semantic_score.is_some_and(|score| score < semantic_target) {
QualityDecision::Review
} else {
QualityDecision::Allow
}
}
fn aggregate_scores(stages: &[StageEvaluation]) -> QualityScores {
if stages.is_empty() {
return QualityScores {
deterministic: 1.0,
semantic: None,
requirement_coverage: 1.0,
evidence_coverage: 1.0,
};
}
let count = stages.len() as f64;
let semantic_scores = stages
.iter()
.filter_map(|stage| stage.scores.semantic)
.collect::<Vec<_>>();
QualityScores {
deterministic: stages
.iter()
.map(|stage| stage.scores.deterministic)
.sum::<f64>()
/ count,
semantic: (!semantic_scores.is_empty())
.then(|| semantic_scores.iter().sum::<f64>() / semantic_scores.len() as f64),
requirement_coverage: stages
.iter()
.map(|stage| stage.scores.requirement_coverage)
.sum::<f64>()
/ count,
evidence_coverage: stages
.iter()
.map(|stage| stage.scores.evidence_coverage)
.sum::<f64>()
/ count,
}
}
fn aggregate_decision(stages: &[StageEvaluation]) -> QualityDecision {
if stages
.iter()
.any(|stage| stage.decision == QualityDecision::Block)
{
QualityDecision::Block
} else if stages
.iter()
.any(|stage| stage.decision == QualityDecision::Review)
{
QualityDecision::Review
} else {
QualityDecision::Allow
}
}
fn aggregate_confidence(stages: &[StageEvaluation]) -> QualityConfidence {
if stages
.iter()
.any(|stage| stage.confidence == QualityConfidence::Low)
{
QualityConfidence::Low
} else if stages.is_empty() {
QualityConfidence::High
} else {
QualityConfidence::Medium
}
}
fn issue(
severity: EvaluationSeverity,
check: impl Into<String>,
message: impl Into<String>,
) -> EvaluationIssue {
EvaluationIssue {
severity,
check: check.into(),
message: message.into(),
}
}
fn validate_required_sections(
issues: &mut Vec<EvaluationIssue>,
artifact_type: &str,
markdown: &str,
) -> Result<()> {
let required = contract::artifact_spec(artifact_type)
.ok_or_else(|| anyhow!("unknown artifact type: {artifact_type}"))?
.required_sections
.clone();
let headings = collect_headings(markdown)?;
for section in required {
if !headings.contains(&normalize_heading(§ion)) {
issues.push(issue(
EvaluationSeverity::Critical,
"required-section",
format!("seção obrigatória ausente: {section}"),
));
}
}
Ok(())
}
fn validate_semantics(
issues: &mut Vec<EvaluationIssue>,
artifact_type: &str,
markdown: &str,
) -> Result<()> {
let sections = collect_sections(markdown)?;
if contract::artifact_semantic_validations(artifact_type).contains(&"traceability") {
validate_traceability_rule(issues, artifact_type, §ions);
}
if contract::artifact_semantic_validations(artifact_type).contains(&"diagrams") {
validate_diagrams_rule(issues, §ions);
}
if contract::artifact_semantic_validations(artifact_type).contains(&"prompts-agent") {
validate_prompts_agent_rule(issues, §ions)?;
}
if contract::artifact_semantic_validations(artifact_type).contains(&"decision-package") {
validate_checkpoint_rule(issues, §ions);
}
validate_quality_rubric(issues, artifact_type, markdown, §ions)?;
Ok(())
}
fn validate_quality_rubric(
issues: &mut Vec<EvaluationIssue>,
artifact_type: &str,
markdown: &str,
sections: &BTreeMap<String, String>,
) -> Result<()> {
let rules = contract::artifact_quality_rubric(artifact_type);
for rule in rules {
match rule {
"placeholders" => validate_placeholder_rule(issues, markdown)?,
"empty-content" => validate_empty_content_rule(issues, artifact_type, sections),
"unsupported-claims" => validate_unsupported_claims_rule(issues, markdown),
"contradictions" => validate_contradictions_rule(issues, markdown),
"prd-unique-ids" => validate_prd_unique_ids_rule(issues, sections)?,
"prd-testable-requirements" => validate_prd_testability_rule(issues, sections),
"prd-requirement-links" => validate_prd_links_rule(issues, sections)?,
"techspec-contracts" | "techspec-test-strategy" => {
validate_named_section_rule(issues, sections, rule)
}
"tasks-dag" => validate_tasks_dag_rule(issues, sections),
"tasks-agent-prompts" => validate_prompts_agent_rule(issues, sections)?,
"tasks-verifiable-commands" => validate_verifiable_commands_rule(issues, sections),
"execution-files" => validate_named_section_rule(issues, sections, "execution-files"),
"execution-tests" => validate_named_section_rule(issues, sections, "execution-tests"),
"review-verdict" => validate_named_section_rule(issues, sections, "review-verdict"),
"review-evidence" => validate_named_section_rule(issues, sections, "review-evidence"),
"memory-confirmed-decisions" => validate_memory_decisions_rule(issues, sections),
"techspec-prd-coverage" => validate_techspec_prd_coverage_rule(issues, sections),
"stale-context" => {}
_ => {}
}
}
Ok(())
}
fn validate_placeholder_rule(issues: &mut Vec<EvaluationIssue>, markdown: &str) -> Result<()> {
let placeholder =
Regex::new(r"(?im)\b(todo|tbd|fixme|preencher(?: com)?|lorem ipsum)\b|\{\{[^}\n]+\}\}")?;
if placeholder.is_match(markdown) {
issues.push(issue(
EvaluationSeverity::Critical,
"placeholders",
"artefato contém placeholder não resolvido",
));
}
Ok(())
}
fn validate_empty_content_rule(
issues: &mut Vec<EvaluationIssue>,
artifact_type: &str,
sections: &BTreeMap<String, String>,
) {
for required in contract::artifact_spec(artifact_type)
.map(|spec| spec.required_sections.clone())
.unwrap_or_default()
{
if section_body(sections, &required).is_none() {
issues.push(issue(
EvaluationSeverity::Critical,
"empty-content",
format!("seção obrigatória sem conteúdo: {required}"),
));
}
}
}
fn validate_unsupported_claims_rule(issues: &mut Vec<EvaluationIssue>, markdown: &str) {
let claim = markdown.lines().any(|line| {
let normalized = line.to_lowercase();
(normalized.contains("100%") || normalized.contains("zero falha"))
&& !normalized.contains("evid")
});
if claim {
issues.push(issue(
EvaluationSeverity::Warning,
"unsupported-claims",
"claim absoluto deve citar evidência verificável",
));
}
}
fn validate_contradictions_rule(issues: &mut Vec<EvaluationIssue>, markdown: &str) {
let lower = markdown.to_lowercase();
if lower.contains("não será implementado") && lower.contains("será implementado") {
issues.push(issue(
EvaluationSeverity::Critical,
"contradictions",
"artefato declara simultaneamente que o mesmo escopo será e não será implementado",
));
}
}
fn validate_prd_unique_ids_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) -> Result<()> {
let Some(requirements) = section_body(sections, "Requisitos") else {
return Ok(());
};
let id_pattern = Regex::new(r"\bRF-\d+\b")?;
let mut ids = BTreeSet::new();
for id in id_pattern.find_iter(requirements).map(|item| item.as_str()) {
if !ids.insert(id) {
issues.push(issue(
EvaluationSeverity::Critical,
"prd-unique-ids",
format!("ID de requisito duplicado: {id}"),
));
}
}
Ok(())
}
fn validate_prd_testability_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) {
let Some(requirements) = section_body(sections, "Requisitos") else {
return;
};
if requirements.contains("RF-")
&& !section_body(sections, "Critérios de aceite")
.is_some_and(|acceptance| acceptance.contains("Dado") && acceptance.contains("Então"))
{
issues.push(issue(
EvaluationSeverity::Critical,
"prd-testable-requirements",
"requisitos identificados precisam de critérios de aceite testáveis",
));
}
}
fn validate_prd_links_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) -> Result<()> {
let Some(requirements) = section_body(sections, "Requisitos") else {
return Ok(());
};
let ids = Regex::new(r"\bRF-\d+\b")?
.find_iter(requirements)
.map(|item| item.as_str())
.collect::<BTreeSet<_>>();
if ids.len() > 1 {
let linkage = format!(
"{}\n{}",
section_body(sections, "Critérios de aceite").unwrap_or_default(),
section_body(sections, "Métricas").unwrap_or_default()
);
let missing = ids
.into_iter()
.filter(|id| !linkage.contains(id))
.collect::<Vec<_>>();
if !missing.is_empty() {
issues.push(issue(
EvaluationSeverity::Critical,
"prd-requirement-links",
format!(
"requisito sem vínculo de aceite e métrica: {}",
missing.join(", ")
),
));
}
}
Ok(())
}
fn validate_named_section_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
rule: &str,
) {
let heading = match rule {
"techspec-contracts" => "Contratos",
"techspec-test-strategy" => "Testes",
"execution-files" => "Arquivos alterados",
"execution-tests" => "Testes e evidências",
"review-verdict" => "Veredito",
"review-evidence" => "Evidências",
_ => return,
};
if section_body(sections, heading).is_none() {
issues.push(issue(
EvaluationSeverity::Critical,
rule,
format!("rubrica requer conteúdo verificável em `{heading}`"),
));
}
}
fn validate_techspec_prd_coverage_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) {
if !section_body(sections, "Rastreabilidade")
.is_some_and(|body| body.to_lowercase().contains("prd"))
{
issues.push(issue(
EvaluationSeverity::Critical,
"techspec-prd-coverage",
"Tech Spec deve declarar a cobertura do PRD na rastreabilidade",
));
}
}
fn validate_tasks_dag_rule(issues: &mut Vec<EvaluationIssue>, sections: &BTreeMap<String, String>) {
let Some(dependencies) = section_body(sections, "Dependências") else {
return;
};
let cycle = Regex::new(r"\b(T(?:SK)?-\d+)\s*(?:->|→)\s*(T(?:SK)?-\d+)\b")
.map(|pattern| {
pattern.captures_iter(dependencies).any(|captures| {
captures.get(1).map(|value| value.as_str())
== captures.get(2).map(|value| value.as_str())
})
})
.unwrap_or(false);
if cycle {
issues.push(issue(
EvaluationSeverity::Critical,
"tasks-dag",
"DAG de tarefas contém uma dependência cíclica direta",
));
}
}
fn validate_verifiable_commands_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) {
let commands = section_body(sections, "Estratégia de testes").unwrap_or_default();
if !["cargo ", "npm ", "pnpm ", "yarn ", "pytest", "go test"]
.iter()
.any(|marker| commands.contains(marker))
{
issues.push(issue(
EvaluationSeverity::Warning,
"tasks-verifiable-commands",
"Estratégia de testes deveria declarar ao menos um comando verificável",
));
}
}
fn validate_memory_decisions_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) {
if section_body(sections, "Decisões").is_some_and(|body| {
let lower = body.to_lowercase();
lower.contains("hipótese") || lower.contains("rascunho")
}) {
issues.push(issue(
EvaluationSeverity::Critical,
"memory-confirmed-decisions",
"Memory não pode promover hipótese ou rascunho a decisão confirmada",
));
}
}
fn validate_traceability_rule(
issues: &mut Vec<EvaluationIssue>,
artifact_type: &str,
sections: &BTreeMap<String, String>,
) {
let Some(body) = section_body(sections, "Rastreabilidade") else {
issues.push(issue(
EvaluationSeverity::Critical,
"traceability",
"seção `Rastreabilidade` vazia ou sem conteúdo verificável",
));
return;
};
let dependencies = contract::stage_traceability_from(artifact_type);
if dependencies.is_empty() {
return;
}
let haystack = body.to_lowercase();
let related = dependencies.iter().any(|stage| {
haystack.contains(&stage.key.to_lowercase())
|| haystack.contains(&stage.filename.to_lowercase())
|| haystack.contains(&stage.label.to_lowercase())
});
if !related {
let expected = dependencies
.iter()
.map(|stage| stage.filename.as_str())
.collect::<Vec<_>>()
.join(", ");
issues.push(issue(
EvaluationSeverity::Critical,
"traceability",
format!("Rastreabilidade deve citar artefato de origem esperado: {expected}"),
));
}
}
fn validate_diagrams_rule(issues: &mut Vec<EvaluationIssue>, sections: &BTreeMap<String, String>) {
let Some(body) = section_body(sections, "Diagramas") else {
issues.push(issue(
EvaluationSeverity::Critical,
"diagrams",
"seção `Diagramas` vazia ou sem conteúdo verificável",
));
return;
};
let body_lower = body.to_lowercase();
let valid = contract::artifact_allowed_diagram_markers()
.into_iter()
.map(|marker| marker.to_lowercase())
.any(|marker| body_lower.contains(&marker));
let valid_visual_companion = Regex::new(r#"(?i)assets/diagrams/[^\s`)'"<>]+\.(html|svg)"#)
.map(|regex| regex.is_match(body))
.unwrap_or(false);
if !valid && !valid_visual_companion {
issues.push(issue(
EvaluationSeverity::Critical,
"diagrams",
"Diagramas deve conter Mermaid, referência `.excalidraw`, companion `assets/diagrams/*.html|*.svg` ou `Não aplicável`",
));
}
}
fn validate_prompts_agent_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) -> Result<()> {
let backlog_ids = section_body(sections, "Backlog")
.map(extract_task_ids)
.transpose()?
.unwrap_or_default();
let prompt_ids = section_body(sections, "Prompts Agent")
.map(extract_task_ids)
.transpose()?
.unwrap_or_default();
if prompt_ids.is_empty() {
issues.push(issue(
EvaluationSeverity::Critical,
"prompts-agent",
"Prompts Agent deve declarar pelo menos um ID executável (`T-01` ou `TSK-01`)",
));
}
let missing_prompts = backlog_ids
.difference(&prompt_ids)
.cloned()
.collect::<Vec<_>>();
if !missing_prompts.is_empty() {
issues.push(issue(
EvaluationSeverity::Critical,
"prompts-agent",
format!(
"Prompts Agent está sem prompts para: {}",
missing_prompts.join(", ")
),
));
}
Ok(())
}
fn validate_checkpoint_rule(
issues: &mut Vec<EvaluationIssue>,
sections: &BTreeMap<String, String>,
) {
for heading in [
"Artefato",
"Origem",
"Decisão necessária",
"Evidências",
"Riscos pendentes",
] {
if section_body(sections, heading).is_none() {
issues.push(issue(
EvaluationSeverity::Critical,
"decision-package",
format!("Checkpoint deve preencher `{heading}`"),
));
}
}
}
fn validate_context_pack_freshness(
issues: &mut Vec<EvaluationIssue>,
root: &Path,
slug: &str,
stage: &str,
artifact_path: &Path,
) {
if !matches!(stage, "techspec" | "execution" | "review" | "memory") {
return;
}
let pack = root
.join(".sdd/intelligence/context-packs")
.join(slug)
.join(format!("{stage}.md"));
if !pack.exists() {
issues.push(issue(
EvaluationSeverity::Warning,
"context-pack",
format!("Context Pack ausente: {}", pack.display()),
));
return;
}
let pack_mtime = modified(&pack);
let artifact_mtime = modified(artifact_path);
if let (Some(pack_mtime), Some(artifact_mtime)) = (pack_mtime, artifact_mtime) {
if pack_mtime < artifact_mtime {
issues.push(issue(
EvaluationSeverity::Warning,
"context-pack",
format!(
"Context Pack {} é mais antigo que o artefato avaliado",
pack.display()
),
));
}
}
}
fn check_traceability_state(
issues: &mut Vec<EvaluationIssue>,
map: Option<&serde_yaml::Value>,
stage: &str,
expected_file: &str,
artifact_exists: bool,
) {
let Some(map) = map else {
return;
};
let Some(stage_node) = map.get("artifacts").and_then(|node| node.get(stage)) else {
issues.push(issue(
EvaluationSeverity::Critical,
"traceability-map",
format!("traceability-map sem entrada para stage `{stage}`"),
));
return;
};
let file = stage_node.get("file").and_then(serde_yaml::Value::as_str);
if file != Some(expected_file) {
issues.push(issue(
EvaluationSeverity::Warning,
"traceability-map",
format!(
"arquivo registrado para `{stage}` diverge do contrato: {:?} != {expected_file}",
file
),
));
}
let state = stage_node
.get("state")
.and_then(serde_yaml::Value::as_str)
.unwrap_or("unknown");
if artifact_exists && matches!(state, "pending" | "optional") {
issues.push(issue(
EvaluationSeverity::Warning,
"traceability-map",
format!("artefato existe, mas estado no mapa ainda é `{state}`"),
));
}
}
fn artifact_state(map: Option<&serde_yaml::Value>, stage: &str) -> Option<String> {
map.and_then(|map| map.get("artifacts"))
.and_then(|artifacts| artifacts.get(stage))
.and_then(|stage| stage.get("state"))
.and_then(serde_yaml::Value::as_str)
.map(ToString::to_string)
}
fn collect_headings(markdown: &str) -> Result<BTreeSet<String>> {
let re = Regex::new(r"^#{1,6}\s+(.+?)\s*$")?;
Ok(markdown
.lines()
.filter_map(|line| re.captures(line).and_then(|cap| cap.get(1)))
.map(|m| normalize_heading(m.as_str()))
.collect())
}
fn collect_sections(markdown: &str) -> Result<BTreeMap<String, String>> {
let re = Regex::new(r"^#{1,2}\s+(.+?)\s*$")?;
let mut sections = BTreeMap::new();
let mut current_heading: Option<String> = None;
let mut current_body = Vec::new();
for line in markdown.lines() {
if let Some(capture) = re.captures(line) {
if let Some(heading) = current_heading.take() {
sections.insert(heading, current_body.join("\n").trim().to_string());
}
current_heading = capture.get(1).map(|item| normalize_heading(item.as_str()));
current_body.clear();
} else if current_heading.is_some() {
current_body.push(line.to_string());
}
}
if let Some(heading) = current_heading {
sections.insert(heading, current_body.join("\n").trim().to_string());
}
Ok(sections)
}
fn section_body<'a>(sections: &'a BTreeMap<String, String>, heading: &str) -> Option<&'a str> {
sections
.get(&normalize_heading(heading))
.map(String::as_str)
.filter(|body| !body.trim().is_empty())
}
fn extract_task_ids(text: &str) -> Result<BTreeSet<String>> {
let regexes = contract::artifact_prompt_id_patterns()
.into_iter()
.map(Regex::new)
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut ids = BTreeSet::new();
for raw in text.split_whitespace() {
let token = raw.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
if regexes.iter().any(|regex| regex.is_match(token)) {
ids.insert(token.to_string());
}
}
Ok(ids)
}
fn normalize_heading(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_lowercase()
}
fn read_yaml(path: &Path) -> Option<serde_yaml::Value> {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_yaml::from_str(&text).ok())
}
fn modified(path: &Path) -> Option<SystemTime> {
fs::metadata(path)
.ok()
.and_then(|metadata| metadata.modified().ok())
}
fn command_available(command: &str) -> bool {
crate::runtime::platform::find_executable(command).is_some()
}
fn quality_tools(root: &Path) -> Vec<QualityToolStatus> {
vec![
QualityToolStatus {
id: "codegraph".to_string(),
available: command_available("codegraph") && root.join(".codegraph").exists(),
required: false,
command: "codegraph".to_string(),
fallback: "rg/git/context pack".to_string(),
},
QualityToolStatus {
id: "lexa".to_string(),
available: command_available("lexa") && root.join(".lexa/graph.lexa").exists(),
required: false,
command: "lexa".to_string(),
fallback: "rg/git/context pack".to_string(),
},
QualityToolStatus {
id: "coverage".to_string(),
available: command_available("cargo-llvm-cov") || command_available("cargo-tarpaulin"),
required: false,
command: "cargo llvm-cov | cargo tarpaulin".to_string(),
fallback: "cargo test".to_string(),
},
QualityToolStatus {
id: "audit".to_string(),
available: command_available("cargo-audit"),
required: false,
command: "cargo audit".to_string(),
fallback: "dependabot/GitHub advisory review".to_string(),
},
QualityToolStatus {
id: "deny".to_string(),
available: command_available("cargo-deny"),
required: false,
command: "cargo deny check".to_string(),
fallback: "manual dependency/license review".to_string(),
},
QualityToolStatus {
id: "secrets".to_string(),
available: command_available("gitleaks"),
required: false,
command: "gitleaks detect".to_string(),
fallback: "redaction + git diff review".to_string(),
},
]
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StagePassResult {
pub stage: String,
pub status: String,
pub issues_count: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EvalHarnessReport {
pub schema_version: u8,
pub run_id: String,
pub total_stages: usize,
pub passed_stages: usize,
pub pass_rate: f64,
pub status: String,
pub issues_count: usize,
pub stages: Vec<StagePassResult>,
}
pub fn run_eval_harness(
root: &Path,
run_id: Option<&str>,
eval_all: bool,
) -> Result<Vec<EvalHarnessReport>> {
let quality = load_sdd_config(root)?.quality;
let runs_to_eval = resolve_harness_runs(root, run_id, eval_all)?;
let mut reports = Vec::new();
let evaluations_file = root.join(".sdd/evaluations.jsonl");
if let Some(parent) = evaluations_file.parent() {
let _ = fs::create_dir_all(parent);
}
for slug in runs_to_eval {
let orch_eval = evaluate_orchestration_with_quality(root, &slug, quality.clone())?;
let total_stages = orch_eval.stages.len();
let passed_stages = orch_eval
.stages
.iter()
.filter(|s| s.status == "pass")
.count();
let pass_rate = if total_stages > 0 {
passed_stages as f64 / total_stages as f64
} else {
1.0
};
let status = if orch_eval.critical_count == 0 && pass_rate >= 1.0 {
"pass"
} else {
"fail"
};
let stages = orch_eval
.stages
.iter()
.map(|s| StagePassResult {
stage: s.stage.clone(),
status: s.status.clone(),
issues_count: s.issues.len(),
})
.collect();
let harness_report = EvalHarnessReport {
schema_version: 2,
run_id: slug,
total_stages,
passed_stages,
pass_rate,
status: status.to_string(),
issues_count: orch_eval.issue_count,
stages,
};
let event = evaluation_event("eval_harness", &harness_report);
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&evaluations_file)
{
use std::io::Write;
let _ = writeln!(file, "{}", event);
}
reports.push(harness_report);
}
Ok(reports)
}
pub fn read_latest_eval_harness_pass_rate(root: &Path) -> Result<Option<(String, f64)>> {
let evaluations_file = root.join(".sdd/evaluations.jsonl");
if !evaluations_file.exists() {
return Ok(None);
}
let content = fs::read_to_string(&evaluations_file)?;
let mut last_pass_rate = None;
for line in content.lines().rev() {
if line.trim().is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
if val.get("kind").and_then(|k| k.as_str()) == Some("eval_harness") {
if let Some(report) = val.get("report") {
let run_id = report
.get("run_id")
.and_then(|r| r.as_str())
.unwrap_or("unknown")
.to_string();
let pass_rate = report
.get("pass_rate")
.and_then(|p| p.as_f64())
.unwrap_or(0.0);
last_pass_rate = Some((run_id, pass_rate));
break;
}
}
}
}
Ok(last_pass_rate)
}
fn resolve_harness_runs(root: &Path, run_id: Option<&str>, eval_all: bool) -> Result<Vec<String>> {
if let Some(id) = run_id {
return Ok(vec![crate::artifact_slug(id)]);
}
let docs_dir = root.join("docs");
if !docs_dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
if let Ok(dir_entries) = fs::read_dir(&docs_dir) {
for entry in dir_entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("traceability-map.yaml").exists() {
let mtime =
modified(&path.join("traceability-map.yaml")).unwrap_or(SystemTime::UNIX_EPOCH);
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
entries.push((name.to_string(), mtime));
}
}
}
}
if eval_all {
Ok(entries.into_iter().map(|(n, _)| n).collect())
} else if let Some(latest) = entries.into_iter().max_by_key(|(_, mtime)| *mtime) {
Ok(vec![latest.0])
} else {
Ok(Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn evaluation_uses_the_worktree_aware_artifact_locator() {
let root = tempdir().unwrap();
let store = root.path().join(".worktree/flow/docs/flow");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("traceability-map.yaml"),
"orchestration:\n name: Flow\n slug: flow\nartifacts:\n idea:\n file: 01-idea.md\n state: recorded\n",
)
.unwrap();
fs::write(
store.join("01-idea.md"),
"# Ideia\n\n## Rastreabilidade\n\n- Orquestração: Flow\n\n## Problema\n\nProblema real.\n\n## Objetivo\n\nObjetivo.\n\n## Usuários\n\nUsuários.\n\n## Critérios de sucesso\n\n- Resultado mensurável.\n",
)
.unwrap();
let report = evaluate_stage(root.path(), "Flow", "idea").unwrap();
assert!(report.artifact_path.contains(".worktree/flow/docs/flow"));
assert!(!report
.issues
.iter()
.any(|issue| issue.check == "artifact-file"));
}
#[test]
fn eval_harness_evaluates_orchestration_and_persists_report() {
let root = tempdir().unwrap();
let store = root.path().join("docs/test-run");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("traceability-map.yaml"),
"orchestration:\n name: Test Run\n slug: test-run\nartifacts:\n idea:\n file: 01-idea.md\n state: recorded\n",
)
.unwrap();
fs::write(
store.join("01-idea.md"),
"# Ideia\n\n## Rastreabilidade\n\n- Orquestração: Test Run\n- Origem: 00-project-discovery.md\n\n## Problema\n\nProblema real.\n\n## Objetivo\n\nObjetivo.\n\n## Usuários\n\nUsuários.\n\n## Critérios de sucesso\n\n- Resultado mensurável.\n",
)
.unwrap();
let reports = run_eval_harness(root.path(), Some("test-run"), false).unwrap();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].run_id, "test-run");
assert_eq!(reports[0].status, "pass");
let saved_rate = read_latest_eval_harness_pass_rate(root.path()).unwrap();
assert!(saved_rate.is_some());
let (run_id, pass_rate) = saved_rate.unwrap();
assert_eq!(run_id, "test-run");
assert_eq!(pass_rate, 1.0);
}
}