agileplus-trace-validator 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
Documentation
//! AgilePlus trace validator: structural and semantic checks for intent graphs.
//!
//! Public entry point is [`validate_intent_graph`], which composes the
//! individual validator functions across the rule categories. Each category
//! lives in its own submodule:
//!
//! - [`metadata`]: graph-metadata field validation
//! - [`nodes`]: node completeness + ID format
//! - [`edges`]: ontology-driven edge constraints
//! - [`dag`]: acyclicity + DAG flow rules
//! - [`canonical`]: canonical_map field checks
//! - [`ontology`]: bundled ontology re-exports
//!
//! The public API of the crate (`validate_intent_graph`, `is_valid`,
//! `first_error`, `validate_node_ids_only`, `ValidationError`,
//! `ValidationResult`) is preserved by re-exporting each category's public
//! functions from this module.

use agileplus_domain::intent_graph::*;
use thiserror::Error;

mod canonical;
mod dag;
mod edges;
mod metadata;
mod nodes;
mod ontology;
pub mod ontology_ext;

pub use canonical::validate_canonical_map;
pub use dag::{validate_dag_acyclicity, validate_dag_flow};
pub use edges::validate_edge_constraints;
pub use metadata::validate_metadata;
pub use nodes::{validate_node_completeness, validate_node_ids_only};
pub use ontology::{
    DagFlow, DagFlowStage, DagValidationRules, EdgeConstraint, EdgeConstraintEntry,
    EdgeConstraints, Ontology,
};
pub use ontology_ext::{
    extended_relationship_of, validate_extended_edge, validate_extended_edge_constraints,
    EXTENDED_RELATIONSHIP_PROPERTY,
};

/// High-level validation error for intent graphs.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ValidationError {
    #[error("Node ID format error: {0}")]
    InvalidNodeId(String),
    #[error("Missing required field: {0}")]
    MissingRequiredField(String),
    #[error("Edge constraint violation: {0}")]
    EdgeConstraintViolation(String),
    #[error("Cycle detected: {0}")]
    CycleDetected(String),
    #[error("DAG flow violation: {0}")]
    DagFlowViolation(String),
    #[error("Metadata error: {0}")]
    MetadataError(String),
    #[error("Canonical map error: {0}")]
    CanonicalMapError(String),
    #[error("Unknown node referenced in edge: {0}")]
    UnknownNode(String),
    #[error("Self-loop detected: {0}")]
    SelfLoop(String),
}

/// Result type alias for validation.
pub type ValidationResult = Result<(), Vec<ValidationError>>;

/// Validate an entire intent graph.
/// Returns `Ok(())` if valid, or a list of `ValidationError` if issues found.
/// Checks run in order: metadata -> nodes -> edges -> DAG -> flow -> canonical maps.
pub fn validate_intent_graph(graph: &IntentGraph) -> ValidationResult {
    let mut errors = Vec::new();

    errors.extend(validate_metadata(&graph.metadata).err().unwrap_or_default());
    errors.extend(validate_node_completeness(graph).err().unwrap_or_default());
    errors.extend(validate_edge_constraints(graph).err().unwrap_or_default());
    errors.extend(validate_dag_acyclicity(graph).err().unwrap_or_default());
    errors.extend(validate_dag_flow(graph).err().unwrap_or_default());
    errors.extend(validate_canonical_map(graph).err().unwrap_or_default());

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

/// Quick validation that returns a boolean. Suitable for backward-compatible use.
pub fn is_valid(graph: &IntentGraph) -> bool {
    validate_intent_graph(graph).is_ok()
}

/// Quick validation that returns only the first error, if any.
pub fn first_error(graph: &IntentGraph) -> Option<ValidationError> {
    match validate_intent_graph(graph) {
        Ok(()) => None,
        Err(errors) => errors.into_iter().next(),
    }
}

#[cfg(test)]
mod tests {
    #![allow(unused_imports)]
    use super::*;
    use agileplus_domain::builder::*;
    use agileplus_domain::intent_graph::*;

    fn make_valid_graph() -> IntentGraph {
        let mut builder = IntentGraphBuilder::new().with_default_metadata();

        let intent = NodeBuilder::new()
            .id("Intent#user-auth")
            .node_type(NodeType::Intent)
            .dag_stage(DagStage::Intent)
            .title("User Authentication")
            .status(Status::Active)
            .with_meta("user-prompt")
            .build()
            .unwrap();

        let feature = NodeBuilder::new()
            .id("Feature#oauth2-login")
            .node_type(NodeType::Feature)
            .dag_stage(DagStage::Feature)
            .title("OAuth2 Login")
            .status(Status::Draft)
            .with_meta("agent-inference")
            .build()
            .unwrap();

        let test = NodeBuilder::new()
            .id("Test#oauth2-login")
            .node_type(NodeType::Test)
            .dag_stage(DagStage::Test)
            .title("OAuth2 Login Tests")
            .status(Status::Draft)
            .with_meta("agent-inference")
            .build()
            .unwrap();

        builder = builder
            .node(intent.clone())
            .node(feature.clone())
            .node(test.clone());

        // Intent implements Feature
        builder = builder.edge(
            EdgeBuilder::new()
                .auto_id()
                .source("Intent#user-auth")
                .target("Feature#oauth2-login")
                .relationship_type(RelationshipType::Implements)
                .with_meta("agent-inference")
                .build()
                .unwrap(),
        );

        // Feature is tested by Test
        builder = builder.edge(
            EdgeBuilder::new()
                .auto_id()
                .source("Feature#oauth2-login")
                .target("Test#oauth2-login")
                .relationship_type(RelationshipType::Tests)
                .with_meta("agent-inference")
                .build()
                .unwrap(),
        );

        builder.build().unwrap()
    }

    #[test]
    fn test_valid_graph() {
        let graph = make_valid_graph();
        assert!(validate_intent_graph(&graph).is_ok());
    }

    #[test]
    fn test_dag_acyclicity_cycle() {
        let mut graph = make_valid_graph();
        let cycle_edge = EdgeBuilder::new()
            .auto_id()
            .source("Feature#oauth2-login")
            .target("Intent#user-auth")
            .relationship_type(RelationshipType::DerivesFrom)
            .with_meta("test")
            .build()
            .unwrap();
        graph.add_edge(cycle_edge);

        let result = validate_dag_acyclicity(&graph);
        assert!(result.is_err());
        let errs = result.unwrap_err();
        assert!(errs
            .iter()
            .any(|e| matches!(e, ValidationError::CycleDetected(_))));
    }

    #[test]
    fn test_edge_constraint_violation() {
        let mut graph = make_valid_graph();
        let bad_edge = EdgeBuilder::new()
            .auto_id()
            .source("Intent#user-auth")
            .target("Feature#oauth2-login")
            .relationship_type(RelationshipType::Tests)
            .with_meta("test")
            .build()
            .unwrap();
        graph.edges.push(bad_edge);

        let result = validate_edge_constraints(&graph);
        assert!(result.is_err());
        let errs = result.unwrap_err();
        assert!(errs
            .iter()
            .any(|e| matches!(e, ValidationError::EdgeConstraintViolation(_))));
    }

    #[test]
    fn test_node_completeness_missing_title() {
        let mut graph = make_valid_graph();
        graph.nodes[0].title = "".to_string();
        let result = validate_node_completeness(&graph);
        assert!(result.is_err());
    }

    #[test]
    fn test_metadata_empty_version() {
        let mut graph = make_valid_graph();
        graph.metadata.version = "".to_string();
        let result = validate_metadata(&graph.metadata);
        assert!(result.is_err());
    }

    #[test]
    fn test_self_loop() {
        let mut graph = make_valid_graph();
        let self_loop = EdgeBuilder::new()
            .auto_id()
            .source("Intent#user-auth")
            .target("Intent#user-auth")
            .relationship_type(RelationshipType::TracesTo)
            .with_meta("test")
            .build()
            .unwrap();
        graph.add_edge(self_loop);

        let result = validate_edge_constraints(&graph);
        assert!(result.is_err());
        let errs = result.unwrap_err();
        assert!(errs.iter().any(|e| matches!(e, ValidationError::SelfLoop(_))));
    }

    #[test]
    fn test_invalid_node_id() {
        let mut graph = make_valid_graph();
        graph.nodes[0].id = NodeId::new("invalid-id".to_string());
        let result = validate_node_completeness(&graph);
        assert!(result.is_err());
    }

    #[test]
    fn test_dag_flow_root_must_be_intent() {
        let mut graph = make_valid_graph();
        graph.nodes.remove(0);
        graph.edges.clear();
        let result = validate_dag_flow(&graph);
        assert!(result.is_err());
    }

    #[test]
    fn test_backward_compat_is_valid() {
        let graph = make_valid_graph();
        assert!(is_valid(&graph));
        assert!(first_error(&graph).is_none());
    }
}