ripr 0.4.0

Static RIPR mutation-exposure analysis for Rust workspaces
Documentation
use crate::app::Mode;
use crate::app::agent_brief::{
    AgentBriefResolvedWorkingSet, AgentBriefSelectedSeam, AgentBriefSelection,
};
use crate::config::RiprConfig;
use crate::output::agent_seam_packets;
use serde_json::{Value, json};
use std::path::Path;

pub(crate) const AGENT_BRIEF_SCHEMA_VERSION: &str = "0.1";

pub(crate) fn render_agent_brief_json(
    root: &Path,
    mode: &Mode,
    config: &RiprConfig,
    working_set: &AgentBriefResolvedWorkingSet,
    selection: &AgentBriefSelection<'_>,
) -> Result<String, String> {
    let value = json!({
        "schema_version": AGENT_BRIEF_SCHEMA_VERSION,
        "tool": "ripr",
        "scope": "working_set",
        "root": display_path(root),
        "mode": mode.as_str(),
        "config": config_json(config),
        "working_set": working_set_json(working_set),
        "limits": {
            "requested": selection.requested,
            "returned": selection.returned,
            "default": selection.default,
            "hard_cap": selection.hard_cap,
        },
        "top_seams": selection
            .top_seams
            .iter()
            .map(|entry| top_seam_json(entry, root, mode, config))
            .collect::<Vec<_>>(),
        "next": {
            "inspect_packet": format!(
                "ripr check --root {} --mode {} --format agent-seam-packets-json > target/ripr/workflow/agent-seam-packets.json",
                display_path(root),
                mode.as_str()
            ),
            "verify_after_edit": format!(
                "ripr agent verify --root {} --before target/ripr/workflow/before.repo-exposure.json --after target/ripr/workflow/after.repo-exposure.json --json",
                display_path(root)
            ),
        },
        "warnings": &selection.warnings,
    });
    serde_json::to_string_pretty(&value).map_err(|err| err.to_string())
}

fn config_json(config: &RiprConfig) -> Value {
    json!({
        "state": if config.source_path().is_some() { "loaded" } else { "missing" },
        "path": config.source_path().map(display_path),
        "fingerprint": config.source_text().map(config_fingerprint),
    })
}

fn working_set_json(working_set: &AgentBriefResolvedWorkingSet) -> Value {
    json!({
        "source": working_set.source.as_str(),
        "files": working_set.files.iter().map(|path| display_path(path)).collect::<Vec<_>>(),
        "changed_lines": working_set.changed_lines.iter().map(|line| {
            json!({
                "file": display_path(&line.file),
                "line": line.line,
            })
        }).collect::<Vec<_>>(),
        "base": working_set.base.as_deref(),
        "diff": working_set.diff.as_ref().map(|path| display_path(path)),
        "seam_id": working_set.seam_id.as_deref(),
    })
}

fn top_seam_json(
    selected: &AgentBriefSelectedSeam<'_>,
    root: &Path,
    mode: &Mode,
    config: &RiprConfig,
) -> Value {
    let entry = selected.seam;
    let seam = &entry.seam;
    let evidence = &entry.evidence;
    let missing = agent_seam_packets::missing_discriminator_records_for(entry);
    let recommended = agent_seam_packets::recommended_test_for(entry);
    let nearest = agent_seam_packets::nearest_strong_test_to_imitate(evidence);
    let candidate_values = agent_seam_packets::candidate_values_for(entry, &missing);
    let assertion_shape =
        agent_seam_packets::assertion_shape_for(seam.kind(), seam.owner(), evidence);

    json!({
        "seam_id": seam.id().as_str(),
        "owner": seam.owner(),
        "seam_kind": seam.kind().as_str(),
        "file": display_path(seam.file()),
        "line": seam.display_line(),
        "expression": seam.expression(),
        "grip_class": entry.class.as_str(),
        "severity": config.severity().for_seam(entry.class).as_str(),
        "headline_eligible": entry.class.is_headline_eligible(),
        "why_now": {
            "reason": selected.why_now.reason.as_str(),
            "confidence": selected.why_now.confidence.as_str(),
            "evidence": selected.why_now.evidence.as_str(),
        },
        "evidence": {
            "reach": evidence.reach.state.as_str(),
            "activate": evidence.activate.state.as_str(),
            "propagate": evidence.propagate.state.as_str(),
            "observe": evidence.observe.state.as_str(),
            "discriminate": evidence.discriminate.state.as_str(),
        },
        "recommended_test": {
            "name": recommended.name,
            "file": display_text_path(&recommended.file),
            "reason": recommended.reason,
        },
        "nearest_strong_test_to_imitate": nearest.map(|test| json!({
            "name": test.test_name.as_str(),
            "file": display_path(&test.file),
            "line": test.line,
            "oracle_kind": test.oracle_kind.as_str(),
            "oracle_strength": test.oracle_strength.as_str(),
            "relation_reason": test.relation_reason.as_str(),
            "relation_confidence": test.relation_confidence.as_str(),
        })),
        "candidate_values": candidate_values.iter().map(|record| json!({
            "value": record.value.as_str(),
            "reason": record.reason.as_str(),
        })).collect::<Vec<_>>(),
        "missing_discriminators": missing.iter().map(|record| json!({
            "value": record.value.as_str(),
            "reason": record.reason.as_str(),
        })).collect::<Vec<_>>(),
        "assertion_shape": {
            "kind": assertion_shape.kind,
            "example": assertion_shape.example,
        },
        "packet_ref": {
            "format": "agent-seam-packets-json",
            "seam_id": seam.id().as_str(),
        },
        "verification": verification_json(root, mode, &recommended.name),
    })
}

fn verification_json(root: &Path, mode: &Mode, recommended_name: &str) -> Value {
    let root = display_path(root);
    json!({
        "before_snapshot_command": format!(
            "ripr check --root {root} --mode {} --format repo-exposure-json > target/ripr/workflow/before.repo-exposure.json",
            mode.as_str()
        ),
        "after_snapshot_command": format!(
            "ripr check --root {root} --mode {} --format repo-exposure-json > target/ripr/workflow/after.repo-exposure.json",
            mode.as_str()
        ),
        "verify_command": format!("ripr agent verify --root {root} --before target/ripr/workflow/before.repo-exposure.json --after target/ripr/workflow/after.repo-exposure.json --json"),
        "suggested_test_command": format!("cargo test {recommended_name}"),
    })
}

fn display_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn display_text_path(path: &str) -> String {
    path.replace('\\', "/")
}

fn config_fingerprint(source_text: &str) -> String {
    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
    const FNV_PRIME: u64 = 0x100000001b3;
    let mut hash = FNV_OFFSET;
    for byte in source_text.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    format!("fnv1a64:{hash:016x}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analysis::ClassifiedSeam;
    use crate::analysis::seams::{
        ExpectedSink, RepoSeam, RequiredDiscriminator, SeamGripClass, SeamKind,
    };
    use crate::analysis::test_grip_evidence::{
        RelatedTestGrip, RelationConfidence, RelationReason, TestGripEvidence,
    };
    use crate::app::agent_brief::{
        AGENT_BRIEF_HARD_MAX_SEAMS, AgentBriefLine, AgentBriefPolicy, AgentBriefResolvedWorkingSet,
        DEFAULT_AGENT_BRIEF_MAX_SEAMS, select_agent_brief_seams,
    };
    use crate::config::{CONFIG_FILE_NAME, load_for_root};
    use crate::domain::{
        Confidence, MissingDiscriminatorFact, OracleKind, OracleStrength, StageEvidence,
        StageState, ValueFact,
    };
    use serde_json::Value;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn stage(state: StageState) -> StageEvidence {
        StageEvidence::new(state, Confidence::Medium, "test stage")
    }

    fn classified() -> ClassifiedSeam {
        let seam = RepoSeam::new(
            "src/pricing.rs",
            "pricing::discounted_total",
            SeamKind::PredicateBoundary,
            880,
            88,
            "amount >= discount_threshold",
            RequiredDiscriminator::BoundaryValue {
                description: "amount >= discount_threshold".to_string(),
            },
            ExpectedSink::ReturnValue,
        );
        let seam_id = seam.id().clone();
        ClassifiedSeam {
            seam,
            class: SeamGripClass::WeaklyGripped,
            evidence: TestGripEvidence {
                seam_id,
                related_tests: vec![RelatedTestGrip {
                    test_name: "below_threshold_has_no_discount".to_string(),
                    file: PathBuf::from("tests/pricing.rs"),
                    line: 12,
                    oracle_kind: OracleKind::ExactValue,
                    oracle_strength: OracleStrength::Strong,
                    evidence_summary: "exact returned value assertion".to_string(),
                    relation_reason: RelationReason::DirectOwnerCall,
                    relation_confidence: RelationConfidence::High,
                }],
                reach: stage(StageState::Yes),
                activate: stage(StageState::Yes),
                propagate: stage(StageState::Yes),
                observe: stage(StageState::Yes),
                discriminate: stage(StageState::Weak),
                observed_values: Vec::<ValueFact>::new(),
                missing_discriminators: vec![MissingDiscriminatorFact {
                    value: "discount_threshold (equality boundary)".to_string(),
                    reason: "observed values do not include the equality-boundary case".to_string(),
                    flow_sink: None,
                }],
            },
        }
    }

    fn temp_root(label: &str) -> Result<PathBuf, String> {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|err| format!("time moved backwards: {err}"))?
            .as_nanos();
        let root = std::env::temp_dir().join(format!(
            "ripr-agent-brief-render-{label}-{stamp}-{}",
            std::process::id()
        ));
        std::fs::create_dir_all(&root)
            .map_err(|err| format!("create temp root {}: {err}", root.display()))?;
        Ok(root)
    }

    #[test]
    fn agent_brief_json_renders_ranked_seam_summary() -> Result<(), String> {
        let seams = vec![classified()];
        let working_set = AgentBriefResolvedWorkingSet::diff(
            "change.diff",
            vec![AgentBriefLine::new("src/pricing.rs", 88)],
        );
        let config = RiprConfig::default();
        let selection = select_agent_brief_seams(
            &seams,
            &working_set,
            3,
            AgentBriefPolicy::from_config(&config),
        );

        let json = render_agent_brief_json(
            Path::new("."),
            &Mode::Draft,
            &config,
            &working_set,
            &selection,
        )?;
        let value: Value = serde_json::from_str(&json).map_err(|err| err.to_string())?;

        assert_eq!(value["schema_version"], AGENT_BRIEF_SCHEMA_VERSION);
        assert_eq!(value["scope"], "working_set");
        assert_eq!(value["working_set"]["source"], "diff");
        assert_eq!(value["limits"]["returned"], 1);
        assert_eq!(
            value["top_seams"][0]["why_now"]["reason"],
            "changed_line_intersects_seam"
        );
        assert_eq!(
            value["top_seams"][0]["packet_ref"]["format"],
            "agent-seam-packets-json"
        );
        assert_eq!(
            value["top_seams"][0]["nearest_strong_test_to_imitate"]["name"],
            "below_threshold_has_no_discount"
        );
        assert_eq!(
            value["top_seams"][0]["candidate_values"][0]["value"],
            "discount_threshold (equality boundary)"
        );
        assert!(
            value["top_seams"][0]["verification"]["after_snapshot_command"]
                .as_str()
                .is_some_and(|command| command.contains("--format repo-exposure-json"))
        );
        Ok(())
    }

    #[test]
    fn agent_brief_json_renders_loaded_config_and_empty_file_scope() -> Result<(), String> {
        let root = temp_root("loaded-config")?;
        std::fs::write(root.join(CONFIG_FILE_NAME), "[analysis]\nmode = \"fast\"\n")
            .map_err(|err| format!("write ripr.toml: {err}"))?;
        let config = load_for_root(&root)?;
        let working_set =
            AgentBriefResolvedWorkingSet::files(vec![PathBuf::from(".\\src\\pricing.rs")]);
        let selection = AgentBriefSelection {
            requested: 0,
            returned: 0,
            default: DEFAULT_AGENT_BRIEF_MAX_SEAMS,
            hard_cap: AGENT_BRIEF_HARD_MAX_SEAMS,
            top_seams: Vec::new(),
            warnings: vec!["configured-off seam omitted".to_string()],
        };

        let json = render_agent_brief_json(&root, &Mode::Fast, &config, &working_set, &selection)?;
        let value: Value = serde_json::from_str(&json).map_err(|err| err.to_string())?;
        std::fs::remove_dir_all(&root)
            .map_err(|err| format!("remove temp root {}: {err}", root.display()))?;

        assert_eq!(value["config"]["state"], "loaded");
        assert!(
            value["config"]["path"]
                .as_str()
                .is_some_and(|path| path.ends_with(CONFIG_FILE_NAME))
        );
        assert!(
            value["config"]["fingerprint"]
                .as_str()
                .is_some_and(|fingerprint| fingerprint.starts_with("fnv1a64:"))
        );
        assert_eq!(value["working_set"]["source"], "files");
        assert_eq!(value["working_set"]["files"][0], "./src/pricing.rs");
        assert_eq!(value["limits"]["requested"], 0);
        assert_eq!(value["warnings"][0], "configured-off seam omitted");
        assert_eq!(
            value["next"]["inspect_packet"],
            format!(
                "ripr check --root {} --mode fast --format agent-seam-packets-json > target/ripr/workflow/agent-seam-packets.json",
                root.to_string_lossy().replace('\\', "/")
            )
        );
        Ok(())
    }

    #[test]
    fn agent_brief_config_fingerprint_changes_with_source_text() {
        let left = config_fingerprint("[analysis]\nmode = \"fast\"\n");
        let right = config_fingerprint("[analysis]\nmode = \"deep\"\n");

        assert!(left.starts_with("fnv1a64:"));
        assert_eq!(left.len(), "fnv1a64:0000000000000000".len());
        assert_ne!(left, right);
    }
}