repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use super::algorithmic::{AlgorithmicKind, AlgorithmicSignal};
use super::behavioral::{BehavioralKind, BehavioralSignal, BehavioralSignalSource};
use super::taint::{SinkKind, SourceKind, TaintSignal};
use super::tiered::{ConfidenceTier, build_tiered};
use super::{BoundaryCategory, BoundarySignal};
use crate::review::diff::{ChangeStatus, ChangedFile, ChangedRange};
use std::path::PathBuf;

fn boundary(category: BoundaryCategory, path: &str) -> BoundarySignal {
    BoundarySignal {
        category,
        path: path.to_string(),
        status: ChangeStatus::Modified,
        blast_radius: 0,
    }
}

fn behavioral(kind: BehavioralKind, path: &str) -> BehavioralSignal {
    BehavioralSignal {
        kind,
        path: path.to_string(),
        line: 1,
        detail: "detail".to_string(),
        source: BehavioralSignalSource::Ast,
    }
}

fn algorithmic(kind: AlgorithmicKind, path: &str) -> AlgorithmicSignal {
    AlgorithmicSignal {
        kind,
        path: path.to_string(),
        line: 1,
        detail: "detail".to_string(),
    }
}

fn taint(sink: SinkKind, path: &str) -> TaintSignal {
    TaintSignal {
        source: SourceKind::HttpRequest,
        sink,
        path: path.to_string(),
        line: 1,
        detail: "detail".to_string(),
    }
}

/// A large diff with `count` files of 40 changed lines each (no signals).
fn large_diff(count: usize) -> Vec<ChangedFile> {
    (0..count)
        .map(|index| ChangedFile {
            path: PathBuf::from(format!("src/f{index}.rs")),
            status: ChangeStatus::Modified,
            ranges: vec![ChangedRange { start: 1, end: 40 }],
            hunks: Vec::new(),
        })
        .collect()
}

#[test]
fn boundary_signals_land_in_definitely() {
    let tiered = build_tiered(
        &[boundary(BoundaryCategory::AccessControl, "src/auth.ts")],
        &[],
        &[],
        &[],
        &[],
    );
    assert_eq!(tiered.definitely.len(), 1);
    assert!(tiered.maybe.is_empty());
    assert!(tiered.noise.is_empty());
}

#[test]
fn behavioral_kinds_tier_by_sensitivity() {
    let signals = [
        behavioral(BehavioralKind::EnvVarIntroduced, "src/config.ts"),
        behavioral(BehavioralKind::NetworkCallAdded, "src/api.ts"),
    ];
    let tiered = build_tiered(&[], &signals, &[], &[], &[]);
    // env var is a definite boundary crossing; a plain network call is maybe.
    assert_eq!(tiered.definitely.len(), 1);
    assert_eq!(tiered.maybe.len(), 1);
}

#[test]
fn coarse_behavioral_signals_are_demoted_to_noise() {
    // An AuthCheckRemoved from the coarse (non-AST) fallback would normally be
    // "definitely sensitive"; its CoarseFallback source drops it to the noise tier.
    let coarse = BehavioralSignal {
        kind: BehavioralKind::AuthCheckRemoved,
        path: "src/legacy.php".to_string(),
        line: 1,
        detail: "Authentication/authorization check removed (coarse fallback)".to_string(),
        source: BehavioralSignalSource::CoarseFallback,
    };
    let tiered = build_tiered(&[], &[coarse], &[], &[], &[]);
    assert!(tiered.definitely.is_empty());
    assert!(tiered.maybe.is_empty());
    assert_eq!(tiered.noise.len(), 1);
    assert_eq!(tiered.noise[0].tier, ConfidenceTier::LargeDiffOrNoise);
}

#[test]
fn same_kind_is_definitely_when_ast_sourced() {
    // The identical kind, but AST-sourced, stays in the definitely tier — proving
    // the tier keys off `source`, not the kind or the detail text.
    let ast = BehavioralSignal {
        kind: BehavioralKind::AuthCheckRemoved,
        path: "src/auth.ts".to_string(),
        line: 1,
        detail: "Authentication/authorization check removed (coarse fallback)".to_string(),
        source: BehavioralSignalSource::Ast,
    };
    let tiered = build_tiered(&[], &[ast], &[], &[], &[]);
    assert_eq!(tiered.definitely.len(), 1);
    assert!(tiered.noise.is_empty());
}

#[test]
fn network_call_in_an_auth_path_is_escalated() {
    let tiered = build_tiered(
        &[boundary(BoundaryCategory::AccessControl, "src/auth.ts")],
        &[behavioral(BehavioralKind::NetworkCallAdded, "src/auth.ts")],
        &[],
        &[],
        &[],
    );
    // The boundary itself plus the network call in that auth file are both definite.
    assert_eq!(tiered.definitely.len(), 2);
    assert!(tiered.maybe.is_empty());
}

#[test]
fn algorithmic_signals_are_maybe() {
    let tiered = build_tiered(
        &[],
        &[],
        &[algorithmic(
            AlgorithmicKind::NestedLoopIntroduced,
            "src/x.rs",
        )],
        &[],
        &[],
    );
    assert_eq!(tiered.maybe.len(), 1);
    assert!(tiered.definitely.is_empty());
}

#[test]
fn taint_tiers_by_sink_severity() {
    // SQL/exec injection is top tier; filesystem/network reach is maybe.
    let tiered = build_tiered(
        &[],
        &[],
        &[],
        &[
            taint(SinkKind::Sql, "src/db.ts"),
            taint(SinkKind::FsWrite, "src/files.ts"),
        ],
        &[],
    );
    assert_eq!(tiered.definitely.len(), 1);
    assert_eq!(tiered.maybe.len(), 1);
}

#[test]
fn has_taint_signal_detects_taint_family_only() {
    let with_taint = build_tiered(&[], &[], &[], &[taint(SinkKind::Sql, "src/db.ts")], &[]);
    assert!(with_taint.has_taint_signal());

    let without_taint = build_tiered(
        &[boundary(BoundaryCategory::AccessControl, "src/auth.ts")],
        &[behavioral(BehavioralKind::FsWriteAdded, "src/f0.rs")],
        &[],
        &[],
        &[],
    );
    assert!(!without_taint.has_taint_signal());
}

#[test]
fn noise_tier_fires_only_for_a_large_diff_with_nothing_flagged() {
    let tiered = build_tiered(&[], &[], &[], &[], &large_diff(6));
    assert_eq!(tiered.noise.len(), 1);
    assert_eq!(tiered.noise[0].tier, ConfidenceTier::LargeDiffOrNoise);
}

#[test]
fn noise_tier_is_suppressed_when_a_signal_is_present() {
    let tiered = build_tiered(
        &[],
        &[behavioral(BehavioralKind::FsWriteAdded, "src/f0.rs")],
        &[],
        &[],
        &large_diff(6),
    );
    assert!(tiered.noise.is_empty());
    assert_eq!(tiered.maybe.len(), 1);
}

#[test]
fn small_diff_with_nothing_flagged_is_silent() {
    let tiered = build_tiered(&[], &[], &[], &[], &large_diff(2));
    assert!(tiered.is_empty());
}

#[test]
fn definitely_sensitive_signals_include_verification_plan() {
    let tiered = build_tiered(
        &[boundary(BoundaryCategory::AccessControl, "src/auth.ts")],
        &[],
        &[],
        &[],
        &[],
    );

    let plan = tiered.definitely[0]
        .verification_plan
        .as_ref()
        .expect("definitely sensitive review signals should include a verification plan");

    assert!(plan.steps.len() >= 3);
    assert!(plan.steps[0].contains("src/auth.ts"));
    assert!(
        plan.steps
            .iter()
            .any(|step| step.contains("static review evidence only"))
    );
}

#[test]
fn maybe_and_noise_signals_do_not_get_verification_plans() {
    let maybe = build_tiered(
        &[],
        &[behavioral(BehavioralKind::NetworkCallAdded, "src/api.ts")],
        &[],
        &[],
        &[],
    );
    assert!(maybe.definitely.is_empty());
    assert!(maybe.maybe[0].verification_plan.is_none());

    let noise = build_tiered(&[], &[], &[], &[], &large_diff(6));
    assert!(noise.noise[0].verification_plan.is_none());
}

#[test]
fn review_signal_verification_plans_are_deterministic() {
    let left = build_tiered(
        &[],
        &[behavioral(
            BehavioralKind::EnvVarIntroduced,
            "src/config.ts",
        )],
        &[],
        &[],
        &[],
    );
    let right = build_tiered(
        &[],
        &[behavioral(
            BehavioralKind::EnvVarIntroduced,
            "src/config.ts",
        )],
        &[],
        &[],
        &[],
    );

    assert_eq!(
        left.definitely[0].verification_plan,
        right.definitely[0].verification_plan
    );
}

#[test]
fn review_signal_verification_plan_serializes_as_stable_steps_array() {
    let tiered = build_tiered(
        &[boundary(BoundaryCategory::AccessControl, "src/auth.ts")],
        &[],
        &[],
        &[],
        &[],
    );

    let value = serde_json::to_value(&tiered.definitely[0])
        .expect("review signals should serialize to JSON");
    let steps = value
        .get("verification_plan")
        .and_then(|plan| plan.get("steps"))
        .and_then(serde_json::Value::as_array)
        .expect("verification_plan.steps should be a stable JSON array");

    assert_eq!(steps.len(), 3);
    assert_eq!(
        steps[0].as_str(),
        Some(
            "Open src/auth.ts and confirm the changed diff still supports the review signal: access control changed."
        )
    );
    assert!(steps.iter().any(|step| {
        step.as_str()
            .is_some_and(|step| step.contains("static review evidence only"))
    }));
}

#[test]
fn maybe_signal_omits_verification_plan_in_json_contract() {
    let tiered = build_tiered(
        &[],
        &[behavioral(BehavioralKind::NetworkCallAdded, "src/api.ts")],
        &[],
        &[],
        &[],
    );

    let value =
        serde_json::to_value(&tiered.maybe[0]).expect("review signals should serialize to JSON");

    assert!(value.get("verification_plan").is_none());
}