dpcs 0.13.1

Reference implementation of the Data Pipeline Contract Standard (DPCS)
Documentation
//! Graph validation phase.

use crate::diagnostics::{categories, Diagnostic, ValidationReport};
use crate::model::{AnalysisContext, DependencyGraph, PipelineContract};

#[allow(dead_code)]
/// Validate graph edges and detect prohibited cycles.
pub fn validate(contract: &PipelineContract) -> ValidationReport {
    let ctx = AnalysisContext::build(contract);
    validate_with_context(&ctx)
}

/// Validate graph edges using a shared analysis context.
pub fn validate_with_context(ctx: &AnalysisContext<'_>) -> ValidationReport {
    let contract = ctx.contract;
    let mut report = ValidationReport::new();
    let step_ids = &ctx.step_ids;

    for (index, point) in contract.graph.entry_points.iter().enumerate() {
        if point.trim().is_empty() {
            continue;
        }
        if !step_ids.contains(point.as_str()) {
            report.push(
                Diagnostic::error(
                    "DPCS-GRP-007",
                    categories::GRAPH,
                    format!("graph entry point references unknown step `{point}`"),
                )
                .with_object_ref(format!("graph.entryPoints[{index}]"))
                .with_remediation(
                    "Declare graph.entryPoints that refer to declared step identifiers",
                ),
            );
        }
    }

    for (index, point) in contract.graph.exit_points.iter().enumerate() {
        if point.trim().is_empty() {
            continue;
        }
        if !step_ids.contains(point.as_str()) {
            report.push(
                Diagnostic::error(
                    "DPCS-GRP-008",
                    categories::GRAPH,
                    format!("graph exit point references unknown step `{point}`"),
                )
                .with_object_ref(format!("graph.exitPoints[{index}]"))
                .with_remediation(
                    "Declare graph.exitPoints that refer to declared step identifiers",
                ),
            );
        }
    }

    for (index, edge) in contract.graph.edges.iter().enumerate() {
        let object_ref = format!("graph.edges[{index}]");

        if edge.from.trim().is_empty() || edge.to.trim().is_empty() {
            report.push(
                Diagnostic::error(
                    "DPCS-GRP-001",
                    categories::GRAPH,
                    "graph edge endpoints must not be empty",
                )
                .with_object_ref(object_ref.clone()),
            );
            continue;
        }

        if !step_ids.contains(edge.from.as_str()) {
            report.push(
                Diagnostic::error(
                    "DPCS-GRP-002",
                    categories::GRAPH,
                    format!("graph edge references unknown step `{}`", edge.from),
                )
                .with_object_ref(object_ref.clone())
                .with_remediation("Ensure edge endpoints refer to declared step identifiers"),
            );
        }

        if !step_ids.contains(edge.to.as_str()) {
            report.push(
                Diagnostic::error(
                    "DPCS-GRP-003",
                    categories::GRAPH,
                    format!("graph edge references unknown step `{}`", edge.to),
                )
                .with_object_ref(object_ref)
                .with_remediation("Ensure edge endpoints refer to declared step identifiers"),
            );
        }
    }

    for duplicate in DependencyGraph::duplicate_edges(contract) {
        let kind_label = duplicate
            .kind
            .as_deref()
            .map(|kind| format!(" (kind `{kind}`)"))
            .unwrap_or_else(|| " (untyped)".to_string());
        report.push(
            Diagnostic::error(
                "DPCS-GRP-005",
                categories::GRAPH,
                format!(
                    "duplicate graph edge from `{}` to `{}`{kind_label}",
                    duplicate.from, duplicate.to
                ),
            )
            .with_object_ref(format!("graph.edges[{}]", duplicate.duplicate_index))
            .with_remediation("Remove duplicate edges from graph.edges"),
        );
    }

    let dependency_graph = &ctx.graph;
    let cycle = dependency_graph.find_cycle();
    let has_cycle = cycle.is_some();

    if let Some(cycle) = cycle {
        let cycle_node = cycle.first().cloned().unwrap_or_default();
        report.push(
            Diagnostic::error(
                "DPCS-GRP-004",
                categories::GRAPH,
                format!("pipeline graph contains a prohibited cycle involving `{cycle_node}`"),
            )
            .with_object_ref("graph")
            .with_remediation(
                "Remove cyclic dependencies from graph.edges, controlFlow, or dataFlow",
            ),
        );
    }

    if !has_cycle {
        let has_declared_entry_points = contract
            .graph
            .entry_points
            .iter()
            .any(|id| !id.trim().is_empty());
        let unreachable = dependency_graph.unreachable_steps(contract);
        for step_id in unreachable {
            let message = if has_declared_entry_points {
                format!("pipeline step `{step_id}` is unreachable from graph entry points")
            } else {
                format!("pipeline step `{step_id}` is unreachable from graph roots")
            };
            report.push(
                Diagnostic::error("DPCS-GRP-006", categories::GRAPH, message)
                    .with_object_ref(format!("steps.{step_id}"))
                    .with_remediation(
                        "Declare graph.entryPoints or add edges so every step is reachable",
                    ),
            );
        }
    }

    report
}