agent-spec 1.1.0

Intent compiler for AI agent coding: human intent compiles through requirement IR into verifiable task contracts, mechanically verified against the code
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum QaClass {
    A,
    B,
    C,
}

impl QaClass {
    pub fn try_parse(value: &str) -> Result<Self, String> {
        match value.trim().to_ascii_uppercase().as_str() {
            "A" => Ok(QaClass::A),
            "B" => Ok(QaClass::B),
            "C" => Ok(QaClass::C),
            other => Err(format!(
                "unsupported QA risk class `{other}`; expected A, B, or C"
            )),
        }
    }

    pub fn parse(value: Option<&str>) -> Self {
        value
            .and_then(|value| Self::try_parse(value).ok())
            .unwrap_or(QaClass::B)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QaEvidenceKind {
    Lifecycle,
    Trace,
    TargetedTests,
    AdversarialReview,
}

pub fn required_evidence_for(class: QaClass) -> Vec<QaEvidenceKind> {
    match class {
        QaClass::A => vec![
            QaEvidenceKind::Lifecycle,
            QaEvidenceKind::Trace,
            QaEvidenceKind::TargetedTests,
            QaEvidenceKind::AdversarialReview,
        ],
        QaClass::B => vec![QaEvidenceKind::Lifecycle, QaEvidenceKind::Trace],
        QaClass::C => vec![QaEvidenceKind::Lifecycle],
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct QaEvidenceState {
    pub lifecycle: bool,
    pub trace: bool,
    pub targeted_tests: bool,
    pub adversarial_review: bool,
}

pub fn missing_evidence(class: QaClass, state: QaEvidenceState) -> Vec<QaEvidenceKind> {
    required_evidence_for(class)
        .into_iter()
        .filter(|kind| match kind {
            QaEvidenceKind::Lifecycle => !state.lifecycle,
            QaEvidenceKind::Trace => !state.trace,
            QaEvidenceKind::TargetedTests => !state.targeted_tests,
            QaEvidenceKind::AdversarialReview => !state.adversarial_review,
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_qa_class_a_requires_lifecycle_trace_targeted_tests_and_adversarial_review() {
        let evidence = required_evidence_for(QaClass::A);
        assert!(evidence.contains(&QaEvidenceKind::Lifecycle));
        assert!(evidence.contains(&QaEvidenceKind::Trace));
        assert!(evidence.contains(&QaEvidenceKind::TargetedTests));
        assert!(evidence.contains(&QaEvidenceKind::AdversarialReview));
    }

    #[test]
    fn test_qa_class_defaults_to_b_for_unknown_values() {
        assert_eq!(QaClass::parse(Some("A")), QaClass::A);
        assert_eq!(QaClass::parse(Some("B")), QaClass::B);
        assert_eq!(QaClass::parse(Some("C")), QaClass::C);
        assert_eq!(QaClass::parse(Some("unknown")), QaClass::B);
        assert_eq!(QaClass::parse(None), QaClass::B);
        assert!(QaClass::try_parse("unknown").is_err());
    }

    #[test]
    fn test_qa_gate_reports_missing_class_a_and_b_evidence() {
        let state = QaEvidenceState {
            lifecycle: true,
            trace: false,
            targeted_tests: true,
            adversarial_review: false,
        };
        assert_eq!(
            missing_evidence(QaClass::A, state),
            vec![QaEvidenceKind::Trace, QaEvidenceKind::AdversarialReview]
        );
        assert_eq!(
            missing_evidence(QaClass::B, state),
            vec![QaEvidenceKind::Trace]
        );
        assert!(missing_evidence(QaClass::C, state).is_empty());
    }
}