ripr 0.10.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use super::{
    SCHEMA_VERSION, STATIC_EVIDENCE_BOUNDARY, rendering::expected_output_state_for_selected,
    start_here_output_state_is_known, string_path,
};
use serde_json::Value;
use std::fs;
use std::path::Path;

pub(super) fn validate_start_here_packet(
    json_path: &Path,
    markdown_path: &Path,
) -> Result<Value, String> {
    let text = fs::read_to_string(json_path)
        .map_err(|err| format!("missing or unreadable {}: {err}", json_path.display()))?;
    let packet: Value = serde_json::from_str(&text)
        .map_err(|err| format!("{} is not valid JSON: {err}", json_path.display()))?;
    let mut violations = Vec::new();
    expect_string(&packet, "schema_version", SCHEMA_VERSION, &mut violations);
    expect_string(&packet, "tool", "ripr", &mut violations);
    expect_string(&packet, "kind", "first_pr_start_here", &mut violations);
    match packet.get("status").and_then(Value::as_str) {
        Some("actionable" | "blocked" | "no_action") => {}
        Some(status) => violations.push(format!("status {status:?} is not contract-valid")),
        None => violations.push("status is missing or not a string".to_string()),
    }
    expect_string(&packet, "posture", "advisory", &mut violations);
    if let Some(selected) = packet.get("selected").filter(|value| value.is_object()) {
        validate_selected_state(
            packet
                .get("status")
                .and_then(Value::as_str)
                .unwrap_or("unknown"),
            selected,
            &mut violations,
        );
    } else {
        violations.push("selected is missing or not an object".to_string());
    }
    if !packet.get("commands").is_some_and(Value::is_object) {
        violations.push("commands is missing or not an object".to_string());
    }
    if !packet.get("artifacts").is_some_and(Value::is_array) {
        violations.push("artifacts is missing or not an array".to_string());
    }
    if let Some(preflight) = packet.get("preflight") {
        validate_preflight(preflight, &mut violations);
    }
    if !markdown_path.exists() {
        violations.push(format!("{} is missing", markdown_path.display()));
    }
    if violations.is_empty() {
        Ok(packet)
    } else {
        Err(format!(
            "first-pr start-here contract violations:\n{}",
            violations
                .iter()
                .map(|violation| format!("- {violation}"))
                .collect::<Vec<_>>()
                .join("\n")
        ))
    }
}

fn validate_preflight(preflight: &Value, violations: &mut Vec<String>) {
    match preflight.get("status").and_then(Value::as_str) {
        Some("ready" | "needs_attention") => {}
        Some(status) => violations.push(format!("preflight.status {status:?} is not valid")),
        None => violations.push("preflight.status is missing or not a string".to_string()),
    }
    match preflight.get("mode").and_then(Value::as_str) {
        Some("write" | "check") => {}
        Some(mode) => violations.push(format!("preflight.mode {mode:?} is not valid")),
        None => violations.push("preflight.mode is missing or not a string".to_string()),
    }
    let Some(checks) = preflight.get("checks").and_then(Value::as_array) else {
        violations.push("preflight.checks is missing or not an array".to_string());
        return;
    };
    for check in checks {
        if check.get("id").and_then(Value::as_str).is_none() {
            violations.push("preflight check id is missing or not a string".to_string());
        }
        match check.get("status").and_then(Value::as_str) {
            Some("ok" | "needs_attention" | "no_action" | "defaulted" | "will_create") => {}
            Some(status) => {
                violations.push(format!("preflight check status {status:?} is not valid"));
            }
            None => {
                violations.push("preflight check status is missing or not a string".to_string())
            }
        }
        if check.get("message").and_then(Value::as_str).is_none() {
            violations.push("preflight check message is missing or not a string".to_string());
        }
    }
}

pub(super) fn validate_selected_state(
    status: &str,
    selected: &Value,
    violations: &mut Vec<String>,
) {
    let Some(state) = selected.get("state").and_then(Value::as_str) else {
        violations.push("selected.state is missing or not a string".to_string());
        return;
    };
    let expected_output_state = expected_output_state_for_selected(state, selected);
    match selected.get("output_state").and_then(Value::as_str) {
        Some(output_state) if output_state == expected_output_state => {}
        Some(output_state) if !start_here_output_state_is_known(output_state) => violations.push(
            format!("selected.output_state {output_state:?} is not contract-valid"),
        ),
        Some(output_state) => violations.push(format!(
            "selected.output_state must be {expected_output_state:?} for state {state:?}, found {output_state:?}"
        )),
        None => violations.push("selected.output_state is missing or not a string".to_string()),
    }
    let expected_status = match state {
        "top_gap" => "actionable",
        "missing_artifact" | "malformed_artifact" | "stale_artifact" | "wrong_root"
        | "blocked_artifact" | "timeout" => "blocked",
        "empty_diff" | "no_action" => "no_action",
        other => {
            violations.push(format!("selected.state {other:?} is not contract-valid"));
            return;
        }
    };
    if status != expected_status {
        violations.push(format!(
            "selected.state {state:?} requires status {expected_status:?}, found {status:?}"
        ));
    }
    if state == "top_gap" {
        validate_top_gap_contract(selected, violations);
    }
}

fn validate_top_gap_contract(selected: &Value, violations: &mut Vec<String>) {
    for (path, label) in [
        (&["kind"][..], "top actionable gap"),
        (&["changed_behavior"][..], "changed behavior"),
        (&["why"][..], "why this matters"),
        (
            &["current_evidence_strength"][..],
            "current evidence strength",
        ),
        (&["missing_discriminator"][..], "missing discriminator"),
        (&["focused_proof_intent"][..], "focused proof intent"),
        (&["verify_command"][..], "verify command"),
    ] {
        if string_path(selected, path).is_none() {
            violations.push(format!("selected top_gap must name {label}"));
        }
    }
    if string_path(selected, &["receipt_command"]).is_none()
        && string_path(selected, &["receipt_path"]).is_none()
    {
        violations.push("selected top_gap must name receipt command or path".to_string());
    }
    match string_path(selected, &["static_evidence_boundary"]) {
        Some(boundary) if boundary == STATIC_EVIDENCE_BOUNDARY => {}
        Some(boundary) => violations.push(format!(
            "selected.static_evidence_boundary must be {STATIC_EVIDENCE_BOUNDARY:?}, found {boundary:?}"
        )),
        None => violations.push("selected top_gap must name static_evidence_boundary".to_string()),
    }
}

fn expect_string(packet: &Value, key: &str, expected: &str, violations: &mut Vec<String>) {
    match packet.get(key).and_then(Value::as_str) {
        Some(actual) if actual == expected => {}
        Some(actual) => violations.push(format!("{key} is {actual:?}, expected {expected:?}")),
        None => violations.push(format!("{key} is missing or not a string")),
    }
}