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 crate::spec_core::Section;
use crate::spec_knowledge::{build_requirement_graph, build_requirement_plan};
use crate::spec_qa::{QaClass, QaEvidenceKind, required_evidence_for};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TestObligationSet {
    pub version: u32,
    pub obligations: Vec<TestObligation>,
    pub diagnostics: Vec<TestObligationDiagnostic>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TestObligation {
    pub requirement_id: String,
    pub scenario_name: String,
    pub suggested_selector: String,
    pub verification_strength: String,
    pub spec_path: Option<PathBuf>,
    pub required_evidence: Vec<QaEvidenceKind>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TestObligationDiagnostic {
    pub code: String,
    pub severity: String,
    pub requirement_id: Option<String>,
    pub message: String,
}

pub fn build_test_obligations(knowledge_dir: &Path, specs_dir: &Path) -> TestObligationSet {
    let graph = build_requirement_graph(knowledge_dir);
    let plan = build_requirement_plan(knowledge_dir, specs_dir);
    let mut obligations = Vec::new();
    let mut diagnostics = plan
        .diagnostics
        .iter()
        .map(|diagnostic| TestObligationDiagnostic {
            code: diagnostic.code.clone(),
            severity: diagnostic.severity.clone(),
            requirement_id: diagnostic.requirement_id.clone(),
            message: diagnostic.message.clone(),
        })
        .collect::<Vec<_>>();
    diagnostics.extend(
        plan.parse_errors
            .iter()
            .map(|error| TestObligationDiagnostic {
                code: if error.message.contains("knowledge root") {
                    "knowledge-root-missing".into()
                } else {
                    "knowledge-parse-error".into()
                },
                severity: "error".into(),
                requirement_id: None,
                message: format!("{}: {}", error.path.display(), error.message),
            }),
    );

    for node in graph.nodes {
        let spec_paths = plan
            .coverage
            .iter()
            .find(|coverage| coverage.requirement_id == node.id)
            .map(|coverage| coverage.spec_paths.clone())
            .unwrap_or_default();
        let spec_doc = spec_paths
            .first()
            .and_then(|path| crate::spec_parser::parse_spec(path).ok());
        let qa_class = spec_doc
            .as_ref()
            .map(|doc| QaClass::parse(doc.meta.risk.as_deref()))
            .unwrap_or(QaClass::B);
        let selectors = spec_doc
            .as_ref()
            .map(scenario_selectors)
            .unwrap_or_default();

        for scenario in node.scenarios {
            let suggested_selector = selectors
                .get(&scenario.name)
                .cloned()
                .unwrap_or_else(|| selector_from_scenario(&scenario.name));
            obligations.push(TestObligation {
                requirement_id: node.id.clone(),
                scenario_name: scenario.name.clone(),
                suggested_selector,
                verification_strength: "contract".into(),
                spec_path: spec_paths.first().cloned(),
                required_evidence: required_evidence_for(qa_class),
            });
        }
        if spec_paths.is_empty() {
            diagnostics.push(TestObligationDiagnostic {
                code: "test-obligation-missing-spec".into(),
                severity: "warning".into(),
                requirement_id: Some(node.id),
                message: "requirement has test obligations but no satisfying spec".into(),
            });
        }
    }

    obligations.sort_by(|a, b| {
        a.requirement_id
            .cmp(&b.requirement_id)
            .then_with(|| a.scenario_name.cmp(&b.scenario_name))
    });
    diagnostics.sort_by(|a, b| {
        a.requirement_id
            .cmp(&b.requirement_id)
            .then_with(|| a.code.cmp(&b.code))
    });

    TestObligationSet {
        version: 1,
        obligations,
        diagnostics,
    }
}

fn scenario_selectors(doc: &crate::spec_core::SpecDocument) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    for section in &doc.sections {
        if let Section::AcceptanceCriteria { scenarios, .. } = section {
            for scenario in scenarios {
                if let Some(selector) = &scenario.test_selector {
                    out.insert(scenario.name.clone(), selector.filter.clone());
                }
            }
        }
    }
    out
}

fn selector_from_scenario(name: &str) -> String {
    let mut slug = String::new();
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
        } else if !slug.ends_with('_') {
            slug.push('_');
        }
    }
    format!("test_{}", slug.trim_matches('_'))
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    fn make_temp_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn test_test_obligations_are_derived_from_requirements_and_specs() {
        let dir = make_temp_dir("test-obligations");
        let knowledge = dir.join("knowledge");
        let specs = dir.join("specs");
        fs::create_dir_all(knowledge.join("requirements")).unwrap();
        fs::create_dir_all(&specs).unwrap();

        fs::write(
            knowledge.join("requirements/req-note-create.md"),
            "---\nkind: requirement\nid: REQ-NOTE-CREATE\ntitle: \"Create Note\"\nliveness: auto\n---\n## Problem\nCreate notes.\n## Requirements\n[REQ-NOTE-CREATE] The note store MUST create notes.\n## Scenarios\nScenario: Create note\n  Given an empty store\n  When a note is created\n  Then the returned note appears in the list\n## Source Trace\n- test\n## Open Questions\nNone.\n",
        )
        .unwrap();
        fs::write(
            specs.join("task-req-note-create.spec.md"),
            "spec: task\nname: \"Create Note\"\nsatisfies: [REQ-NOTE-CREATE]\nrisk: A\n---\n## Intent\nCreate note.\n## Completion Criteria\nScenario: Create note\n  Test: note_create_adds_note\n  Given an empty store\n  When a note is created\n  Then the returned note appears in the list\n",
        )
        .unwrap();

        let obligations = build_test_obligations(&knowledge, &specs);

        assert_eq!(obligations.obligations.len(), 1);
        let obligation = &obligations.obligations[0];
        assert_eq!(obligation.requirement_id, "REQ-NOTE-CREATE");
        assert_eq!(obligation.scenario_name, "Create note");
        assert_eq!(obligation.suggested_selector, "note_create_adds_note");
        assert_eq!(obligation.verification_strength, "contract");
        assert_eq!(
            obligation.required_evidence,
            vec![
                crate::spec_qa::QaEvidenceKind::Lifecycle,
                crate::spec_qa::QaEvidenceKind::Trace,
                crate::spec_qa::QaEvidenceKind::TargetedTests,
                crate::spec_qa::QaEvidenceKind::AdversarialReview,
            ]
        );

        fs::remove_dir_all(dir).ok();
    }

    #[test]
    fn test_test_obligations_propagate_missing_and_invalid_compiler_inputs() {
        let dir = make_temp_dir("test-obligations-invalid-input");
        let missing =
            build_test_obligations(&dir.join("missing-knowledge"), &dir.join("missing-specs"));
        assert!(missing.obligations.is_empty());
        assert!(
            missing
                .diagnostics
                .iter()
                .any(|diag| { diag.code == "knowledge-root-missing" && diag.severity == "error" })
        );
        assert!(
            missing
                .diagnostics
                .iter()
                .any(|diag| { diag.code == "spec-root-missing" && diag.severity == "error" })
        );
        fs::remove_dir_all(dir).ok();
    }
}