ripr 0.8.0

Find static mutation-exposure gaps before expensive mutation testing
Documentation
use crate::domain::{ExposureClass, Finding, LanguageId, ProbeFamily};

pub(crate) fn sort_findings(findings: &mut [Finding]) {
    findings.sort_by(|a, b| {
        python_preview_rank(a)
            .cmp(&python_preview_rank(b))
            .then(a.probe.location.file.cmp(&b.probe.location.file))
            .then(a.probe.location.line.cmp(&b.probe.location.line))
            .then(a.probe.family.as_str().cmp(b.probe.family.as_str()))
    });
}

fn python_preview_rank(finding: &Finding) -> u8 {
    if finding.language != Some(LanguageId::Python) {
        return 0;
    }

    if is_repairable_python_finding(finding) {
        let mut rank = 0;
        if !python_owner_is_public(finding) {
            rank += 4;
        }
        if !has_direct_python_test_relation(finding) {
            rank += 8;
        }
        if !has_python_verify_command(finding) {
            rank += 2;
        }
        if !is_core_python_repair_family(&finding.probe.family) {
            rank += 4;
        }
        return rank;
    }

    match finding.class {
        ExposureClass::Exposed => 40,
        ExposureClass::WeaklyExposed => 50,
        ExposureClass::ReachableUnrevealed => 55,
        ExposureClass::NoStaticPath => 60,
        ExposureClass::InfectionUnknown | ExposureClass::PropagationUnknown => 70,
        ExposureClass::StaticUnknown => 80,
    }
}

fn is_repairable_python_finding(finding: &Finding) -> bool {
    finding.class == ExposureClass::WeaklyExposed
        && finding.static_limit_kind.is_none()
        && finding.canonical_gap.is_some()
        && finding.recommended_next_step.is_some()
        && !finding.activation.missing_discriminators.is_empty()
}

fn python_owner_is_public(finding: &Finding) -> bool {
    let Some(owner) = &finding.probe.owner else {
        return false;
    };
    let owner_path = owner.0.rsplit("::").next().unwrap_or(owner.0.as_str());
    let owner_name = owner_path.rsplit('.').next().unwrap_or(owner_path);
    !owner_name.starts_with('_')
}

fn has_direct_python_test_relation(finding: &Finding) -> bool {
    finding.evidence.iter().any(|entry| {
        entry.starts_with("related_test_relation: syntactic_call")
            || entry.starts_with("related_test_relation: import_alias_call")
    })
}

fn has_python_verify_command(finding: &Finding) -> bool {
    finding
        .evidence
        .iter()
        .any(|entry| entry.starts_with("test_verify_command: "))
}

fn is_core_python_repair_family(family: &ProbeFamily) -> bool {
    matches!(
        family,
        ProbeFamily::Predicate
            | ProbeFamily::ReturnValue
            | ProbeFamily::ErrorPath
            | ProbeFamily::FieldConstruction
            | ProbeFamily::SideEffect
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{
        ActivationEvidence, Confidence, DeltaKind, FindingCanonicalGap, MissingDiscriminatorFact,
        OwnerKind, Probe, ProbeFamily, ProbeId, RevealEvidence, RiprEvidence, SourceLocation,
        StageEvidence, StageState, StaticLimitKind, SymbolId,
    };

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

    fn finding(id: &str, file: &str, line: usize, family: ProbeFamily) -> Finding {
        Finding {
            id: id.to_string(),
            canonical_gap: None,
            probe: Probe {
                id: ProbeId(format!("probe-{id}")),
                location: SourceLocation::new(file, line, 1),
                owner: None,
                family,
                delta: DeltaKind::Control,
                before: None,
                after: None,
                expression: "value > threshold".to_string(),
                expected_sinks: Vec::new(),
                required_oracles: Vec::new(),
            },
            class: ExposureClass::WeaklyExposed,
            ripr: RiprEvidence {
                reach: stage(),
                infect: stage(),
                propagate: stage(),
                reveal: RevealEvidence {
                    observe: stage(),
                    discriminate: stage(),
                },
            },
            confidence: 0.0,
            evidence: Vec::new(),
            missing: Vec::new(),
            flow_sinks: Vec::new(),
            activation: ActivationEvidence::default(),
            stop_reasons: Vec::new(),
            related_tests: Vec::new(),
            recommended_next_step: None,
            language: None,
            language_status: None,
            owner_kind: None,
            static_limit_kind: None,
        }
    }

    fn python_finding(
        id: &str,
        file: &str,
        line: usize,
        family: ProbeFamily,
        class: ExposureClass,
    ) -> Finding {
        let mut finding = finding(id, file, line, family.clone());
        finding.language = Some(LanguageId::Python);
        finding.owner_kind = Some(OwnerKind::Function);
        finding.class = class;
        finding.probe.owner = Some(SymbolId(format!("python:{file}::calculate_discount")));
        if finding.class == ExposureClass::WeaklyExposed {
            finding.canonical_gap = Some(FindingCanonicalGap {
                id: format!(
                    "gap:python:{file}:calculate_discount:predicate_boundary:predicate:amount>=threshold"
                ),
                language: "python".to_string(),
                file: file.to_string(),
                owner: "calculate_discount".to_string(),
                behavior_kind: "predicate_boundary".to_string(),
                probe_kind: family.as_str().to_string(),
                normalized_discriminator: "amount>=threshold".to_string(),
            });
            finding.recommended_next_step = Some(
                "Add an exact Python boundary assertion for `amount == threshold`.".to_string(),
            );
            finding
                .activation
                .missing_discriminators
                .push(MissingDiscriminatorFact {
                    value: "amount == threshold".to_string(),
                    reason: "predicate boundary equality is not asserted".to_string(),
                    flow_sink: None,
                });
        }
        finding
    }

    fn with_direct_relation(mut finding: Finding) -> Finding {
        finding
            .evidence
            .push("related_test_relation: syntactic_call (test_discount)".to_string());
        finding
    }

    fn with_verify_command(mut finding: Finding) -> Finding {
        finding.evidence.push(
            "test_verify_command: pytest tests/test_pricing.py::test_discount (test_discount)"
                .to_string(),
        );
        finding
    }

    fn with_private_owner(mut finding: Finding) -> Finding {
        finding.probe.owner = Some(SymbolId(
            "python:src/pricing.py::_calculate_discount".to_string(),
        ));
        finding
    }

    #[test]
    fn sort_findings_orders_by_file_line_then_probe_family() {
        let mut findings = vec![
            finding(
                "same-line-predicate",
                "src/b.rs",
                10,
                ProbeFamily::Predicate,
            ),
            finding("later-line", "src/a.rs", 20, ProbeFamily::Predicate),
            finding("same-line-error", "src/b.rs", 10, ProbeFamily::ErrorPath),
            finding("earlier-file", "src/a.rs", 5, ProbeFamily::SideEffect),
        ];

        sort_findings(&mut findings);

        let ids: Vec<&str> = findings.iter().map(|finding| finding.id.as_str()).collect();
        assert_eq!(
            ids,
            vec![
                "earlier-file",
                "later-line",
                "same-line-error",
                "same-line-predicate",
            ]
        );
    }

    #[test]
    fn sort_findings_prioritizes_repairable_python_preview_findings() {
        let actionable = with_verify_command(with_direct_relation(python_finding(
            "actionable-direct",
            "src/z_pricing.py",
            10,
            ProbeFamily::Predicate,
            ExposureClass::WeaklyExposed,
        )));
        let private_actionable =
            with_private_owner(with_verify_command(with_direct_relation(python_finding(
                "actionable-private",
                "src/a_private.py",
                1,
                ProbeFamily::Predicate,
                ExposureClass::WeaklyExposed,
            ))));
        let mut exposed = python_finding(
            "already-observed",
            "src/a_observed.py",
            1,
            ProbeFamily::ReturnValue,
            ExposureClass::Exposed,
        );
        exposed.recommended_next_step = None;
        exposed.activation.missing_discriminators.clear();
        let mut heuristic_only = python_finding(
            "heuristic-only",
            "src/a_heuristic.py",
            1,
            ProbeFamily::ReturnValue,
            ExposureClass::WeaklyExposed,
        );
        heuristic_only.recommended_next_step = None;
        heuristic_only.activation.missing_discriminators.clear();
        heuristic_only
            .evidence
            .push("related_test_relation: same_stem (test_price)".to_string());
        let mut static_limit = python_finding(
            "static-limit",
            "src/a_dynamic.py",
            1,
            ProbeFamily::StaticUnknown,
            ExposureClass::StaticUnknown,
        );
        static_limit.static_limit_kind = Some(StaticLimitKind::DynamicDispatch);
        static_limit.recommended_next_step = None;
        static_limit.activation.missing_discriminators.clear();

        let mut findings = vec![
            static_limit,
            heuristic_only,
            exposed,
            private_actionable,
            actionable,
        ];

        sort_findings(&mut findings);

        let ids: Vec<&str> = findings.iter().map(|finding| finding.id.as_str()).collect();
        assert_eq!(
            ids,
            vec![
                "actionable-direct",
                "actionable-private",
                "already-observed",
                "heuristic-only",
                "static-limit",
            ]
        );
    }
}