use anyhow::{anyhow, Context, Result};
use regex::Regex;
use serde::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;
#[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 StageEvaluation {
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>,
}
impl StageEvaluation {
pub fn has_critical(&self) -> bool {
self.issues
.iter()
.any(|issue| issue.severity == EvaluationSeverity::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 orchestration: String,
pub slug: String,
pub status: String,
pub evaluation: OrchestrationEvaluation,
pub tools: Vec<QualityToolStatus>,
pub recommendations: Vec<String>,
}
#[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> {
let slug = crate::artifact_slug(name);
let artifact_dir = root.join("docs").join(&slug);
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_path = artifact_dir.join(&stage.filename);
let artifact_type = stage.artifact_type.clone();
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,
));
}
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,
))
}
pub fn evaluate_orchestration(root: &Path, name: &str) -> Result<OrchestrationEvaluation> {
let slug = crate::artifact_slug(name);
let artifact_dir = root.join("docs").join(&slug);
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 = StageEvaluation {
orchestration: name.to_string(),
slug: slug.clone(),
stage: "idea".to_string(),
artifact_type: stage.artifact_type.clone(),
artifact_path: artifact_dir.join(&stage.filename).display().to_string(),
traceability_map: traceability_map.display().to_string(),
status: "fail".to_string(),
issues: vec![issue(
EvaluationSeverity::Critical,
"traceability-map",
format!("traceability-map ausente em {}", traceability_map.display()),
)],
};
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(root, name, &stage.key)?);
}
}
Ok(orchestration_report(name, slug, stages))
}
pub fn quality_report(root: &Path, name: &str) -> Result<QualityReport> {
let evaluation = evaluate_orchestration(root, name)?;
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"
};
Ok(QualityReport {
orchestration: name.to_string(),
slug: evaluation.slug.clone(),
status: status.to_string(),
evaluation,
tools,
recommendations,
})
}
pub fn evaluation_event(kind: &str, report: &impl Serialize) -> serde_json::Value {
json!({
"schema_version": 1,
"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,
}
}
fn finalize_stage_report(
name: &str,
slug: String,
stage: &str,
artifact_type: Option<String>,
artifact_path: PathBuf,
traceability_map: PathBuf,
issues: Vec<EvaluationIssue>,
) -> StageEvaluation {
let status = if issues
.iter()
.any(|issue| issue.severity == EvaluationSeverity::Critical)
{
"fail"
} else {
"pass"
};
StageEvaluation {
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(),
issues,
}
}
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);
}
Ok(())
}
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(),
},
]
}