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
//! Property-based tests for `agileplus_trace_validator`.
//!
//! Asserts robustness properties of the validator against random inputs:
//! no-panic, idempotency, and self-consistency between `validate_intent_graph`,
//! `is_valid`, and `first_error`. Domain-correctness invariants are covered
//! by the unit tests inside `src/lib.rs`.
//!
//! We deliberately do NOT assert that randomly generated graphs validate
//! cleanly — domain-specific validation rules (DAG shape, leaf types, status
//! stage consistency) are checked by the unit tests with constructed fixtures.

use agileplus_domain::builder::*;
use agileplus_domain::intent_graph::*;
use agileplus_trace_validator::*;
use proptest::prelude::*;
use proptest::strategy::BoxedStrategy;

// =============================================================================
// Arbitrary impls: provide uniform random selection over each enum's variants
// for proptest. These live in the test target because the enum types live in
// a downstream crate (`agileplus-domain`) and the trait (`proptest::Arbitrary`)
// lives in another (`proptest`). The orphan rule forbids impls of a foreign
// trait for a foreign type, so we cannot place these impls here directly.
//
// We therefore wrap each enum in a local newtype defined in this file. Each
// newtype is `#[derive(Debug, Clone, Copy)]` and `pub(crate)` so it satisfies
// the orphan rule (the newtype is local). The `arb_*` strategy functions and
// the `any::<Arb*Type>()` call sites all consume the newtypes; the newtype is
// then unwrapped to the underlying enum before being handed to the builders.
// =============================================================================

#[derive(Debug, Clone, Copy)]
pub(crate) struct ArbNodeType(pub NodeType);

#[derive(Debug, Clone, Copy)]
pub(crate) struct ArbDagStage(pub DagStage);

#[derive(Debug, Clone, Copy)]
pub(crate) struct ArbStatus(pub Status);

#[derive(Debug, Clone, Copy)]
pub(crate) struct ArbRelationshipType(pub RelationshipType);

impl Arbitrary for ArbNodeType {
    type Strategy = BoxedStrategy<Self>;
    type Parameters = ();
    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
        prop_oneof![
            Just(ArbNodeType(NodeType::Intent)),
            Just(ArbNodeType(NodeType::Plan)),
            Just(ArbNodeType(NodeType::Feature)),
            Just(ArbNodeType(NodeType::Story)),
            Just(ArbNodeType(NodeType::Task)),
            Just(ArbNodeType(NodeType::Spec)),
            Just(ArbNodeType(NodeType::Commit)),
            Just(ArbNodeType(NodeType::Test)),
            Just(ArbNodeType(NodeType::PR)),
            Just(ArbNodeType(NodeType::Bug)),
            Just(ArbNodeType(NodeType::Artifact)),
        ]
        .boxed()
    }
}

impl Arbitrary for ArbDagStage {
    type Strategy = BoxedStrategy<Self>;
    type Parameters = ();
    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
        prop_oneof![
            Just(ArbDagStage(DagStage::Intent)),
            Just(ArbDagStage(DagStage::Plan)),
            Just(ArbDagStage(DagStage::Feature)),
            Just(ArbDagStage(DagStage::Story)),
            Just(ArbDagStage(DagStage::Task)),
            Just(ArbDagStage(DagStage::Spec)),
            Just(ArbDagStage(DagStage::Commit)),
            Just(ArbDagStage(DagStage::Test)),
            Just(ArbDagStage(DagStage::Pr)),
            Just(ArbDagStage(DagStage::Bug)),
            Just(ArbDagStage(DagStage::Artifact)),
        ]
        .boxed()
    }
}

impl Arbitrary for ArbStatus {
    type Strategy = BoxedStrategy<Self>;
    type Parameters = ();
    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
        prop_oneof![
            Just(ArbStatus(Status::Draft)),
            Just(ArbStatus(Status::Active)),
            Just(ArbStatus(Status::Completed)),
            Just(ArbStatus(Status::Deprecated)),
            Just(ArbStatus(Status::Rejected)),
            Just(ArbStatus(Status::Open)),
            Just(ArbStatus(Status::InProgress)),
            Just(ArbStatus(Status::Blocked)),
            Just(ArbStatus(Status::Deferred)),
            Just(ArbStatus(Status::Cancelled)),
        ]
        .boxed()
    }
}

impl Arbitrary for ArbRelationshipType {
    type Strategy = BoxedStrategy<Self>;
    type Parameters = ();
    fn arbitrary_with(_: Self::Parameters) -> Self::Strategy {
        prop_oneof![
            Just(ArbRelationshipType(RelationshipType::Implements)),
            Just(ArbRelationshipType(RelationshipType::Tests)),
            Just(ArbRelationshipType(RelationshipType::Covers)),
            Just(ArbRelationshipType(RelationshipType::TracesTo)),
            Just(ArbRelationshipType(RelationshipType::DerivesFrom)),
            Just(ArbRelationshipType(RelationshipType::Resolves)),
            Just(ArbRelationshipType(RelationshipType::Blocks)),
            Just(ArbRelationshipType(RelationshipType::DependsOn)),
        ]
        .boxed()
    }
}

// =============================================================================
// Fixtures: canonical valid graph used as the baseline for invariant tests.
// =============================================================================

/// Canonical valid graph: `Intent -> Feature -> Test`.
///
/// Mirrors the topology used in the `lib.rs` unit tests so any property
/// tested against this fixture is consistent with the in-tree examples.
fn canonical_graph() -> IntentGraph {
    let intent = NodeBuilder::new()
        .id("Intent#canonical")
        .node_type(NodeType::Intent)
        .dag_stage(DagStage::Intent)
        .title("Canonical intent")
        .status(Status::Active)
        .with_meta("validator-proptest")
        .build()
        .expect("intent node");

    let feature = NodeBuilder::new()
        .id("Feature#canonical")
        .node_type(NodeType::Feature)
        .dag_stage(DagStage::Feature)
        .title("Canonical feature")
        .status(Status::Active)
        .with_meta("validator-proptest")
        .build()
        .expect("feature node");

    let test = NodeBuilder::new()
        .id("Test#canonical")
        .node_type(NodeType::Test)
        .dag_stage(DagStage::Test)
        .title("Canonical test")
        .status(Status::Active)
        .with_meta("validator-proptest")
        .build()
        .expect("test node");

    let edge_if = EdgeBuilder::new()
        .auto_id()
        .source("Intent#canonical")
        .target("Feature#canonical")
        .relationship_type(RelationshipType::Implements)
        .with_meta("validator-proptest")
        .build()
        .expect("intent->feature edge");

    let edge_ft = EdgeBuilder::new()
        .auto_id()
        .source("Feature#canonical")
        .target("Test#canonical")
        .relationship_type(RelationshipType::Tests)
        .with_meta("validator-proptest")
        .build()
        .expect("feature->test edge");

    let mut builder = IntentGraphBuilder::new().with_default_metadata();
    for n in vec![intent, feature, test] {
        builder = builder.node(n);
    }
    for e in vec![edge_if, edge_ft] {
        builder = builder.edge(e);
    }
    builder.build().expect("canonical graph builds")
}

/// Sanity: the canonical fixture itself must validate. If this ever fails
/// the upstream validators regressed and every other proptest below is
/// meaningless.
#[test]
fn canonical_graph_validates() {
    let g = canonical_graph();
    let report = validate_intent_graph(&g);
    assert!(
        report.is_ok(),
        "canonical graph failed validation: {:?}",
        report.err()
    );
}

// =============================================================================
// Properties: validator robustness against random inputs.
// =============================================================================

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 64,
        .. ProptestConfig::default()
    })]

    /// Validator must never panic on arbitrary graph input.
    #[test]
    fn validator_never_panics_on_random_graph(graph in arb_intent_graph()) {
        let _ = validate_intent_graph(&graph);
        let _ = validate_node_completeness(&graph);
        let _ = validate_metadata(&graph.metadata);
        let _ = validate_dag_acyclicity(&graph);
        let _ = validate_edge_constraints(&graph);
    }

    /// Running validation twice must produce the same result.
    #[test]
    fn validate_is_idempotent(graph in arb_intent_graph()) {
        let r1 = validate_intent_graph(&graph);
        let r2 = validate_intent_graph(&graph);
        prop_assert_eq!(r1.is_ok(), r2.is_ok());
    }

    /// `first_error` must be `None` exactly when full validation succeeds.
    #[test]
    fn first_error_matches_full(graph in arb_intent_graph()) {
        let full = validate_intent_graph(&graph);
        let first = first_error(&graph);
        prop_assert_eq!(full.is_ok(), first.is_none());
    }

    /// `is_valid` agrees with `validate_intent_graph(...).is_ok()`.
    #[test]
    fn is_valid_agrees_with_full(graph in arb_intent_graph()) {
        let full = validate_intent_graph(&graph);
        prop_assert_eq!(full.is_ok(), is_valid(&graph));
    }

    /// Adding a node to a canonical valid graph must preserve structural
    /// shape (node count grows by one). We do NOT assert validity here —
    /// random statuses are expected to fail specific sub-validators.
    #[test]
    fn adding_node_grows_count(st in any::<ArbStatus>()) {
        let mut g = canonical_graph();
        let before = g.nodes.len();
        let extra = NodeBuilder::new()
            .id("Test#added")
            .node_type(NodeType::Test)
            .dag_stage(DagStage::Test)
            .title("Added test")
            .status(st.0)
            .with_meta("validator-proptest-add")
            .build()
            .expect("added node");
        g.nodes.push(extra);
        prop_assert_eq!(g.nodes.len(), before + 1);
    }
}

// =============================================================================
// Strategies: proptest generators for graphs, nodes, and edges.
// =============================================================================

fn arb_intent_graph() -> BoxedStrategy<IntentGraph> {
    (
        proptest::collection::vec(arb_node(), 0..8),
        proptest::collection::vec(arb_edge(), 0..8),
    )
        .prop_map(|(nodes, edges)| {
            let mut b = IntentGraphBuilder::new().with_default_metadata();
            for n in nodes {
                b = b.node(n);
            }
            for e in edges {
                b = b.edge(e);
            }
            b.build().unwrap_or_else(|_| canonical_graph())
        })
        .boxed()
}

fn arb_node() -> BoxedStrategy<Node> {
    (
        "[A-Z][a-z]+#test-[0-9]+",
        any::<ArbNodeType>(),
        any::<ArbDagStage>(),
        any::<ArbStatus>(),
    )
        .prop_map(|(id, nt, ds, st)| {
            NodeBuilder::new()
                .id(id)
                .node_type(nt.0)
                .dag_stage(ds.0)
                .title("PropTest node")
                .status(st.0)
                .with_meta("validator-proptest")
                .build()
                .expect("arb node")
        })
        .boxed()
}

fn arb_edge() -> BoxedStrategy<Edge> {
    (
        "[A-Z][a-z]+#test-[0-9]+",
        "[A-Z][a-z]+#test-[0-9]+",
        any::<ArbRelationshipType>(),
    )
        .prop_map(|(src, tgt, rt)| {
            EdgeBuilder::new()
                .auto_id()
                .source(&src)
                .target(&tgt)
                .relationship_type(rt.0)
                .with_meta("validator-proptest")
                .build()
                .expect("arb edge")
        })
        .boxed()
}