repopilot 0.11.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use repopilot::baseline::diff::{BaselineStatus, FindingBaselineStatus};
use repopilot::baseline::key::stable_finding_key;
use repopilot::findings::types::{Confidence, Evidence, Finding, FindingCategory, Severity};
use repopilot::graph::CouplingGraph;
use repopilot::risk::{
    FORMULA_VERSION, RiskInputs, RiskPriority, apply_baseline_overlay, apply_blast_radius_overlay,
    apply_cluster_overlay, apply_graph_overlay, apply_review_overlay,
    apply_workspace_hotspot_overlay, assess_finding,
};
use repopilot::scan::facts::FileFacts;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

#[test]
fn file_role_context_changes_risk_without_changing_rule_severity() {
    let finding = finding(
        "code-quality.complex-file",
        "src/domain/user.rs",
        Severity::Medium,
    );
    let production = file("src/domain/user.rs", Some("Rust"));
    let generated = file("src/generated/user.rs", Some("Rust"));
    let config = file("Cargo.toml", Some("TOML"));

    let production_risk = assess_finding(&finding, Some(&production), RiskInputs::default());
    let generated_risk = assess_finding(&finding, Some(&generated), RiskInputs::default());
    let config_risk = assess_finding(&finding, Some(&config), RiskInputs::default());

    assert!(production_risk.score > generated_risk.score);
    assert!(production_risk.score > config_risk.score);
    assert_eq!(finding.severity, Severity::Medium);
    assert!(
        generated_risk
            .signals
            .iter()
            .any(|signal| signal.id == "role.generated")
    );
    assert!(
        config_risk
            .signals
            .iter()
            .any(|signal| signal.id == "role.config")
    );
}

#[test]
fn baseline_overlay_boosts_new_findings_and_downweights_existing_findings() {
    let root = Path::new("/repo");
    let mut findings = vec![
        assessed_finding("security.secret-candidate", "src/new.rs", Severity::High),
        assessed_finding(
            "security.secret-candidate",
            "src/existing.rs",
            Severity::High,
        ),
    ];
    let statuses = vec![
        FindingBaselineStatus {
            key: stable_finding_key(&findings[0], root),
            status: BaselineStatus::New,
        },
        FindingBaselineStatus {
            key: stable_finding_key(&findings[1], root),
            status: BaselineStatus::Existing,
        },
    ];

    apply_baseline_overlay(&mut findings, &statuses, root);

    assert!(findings[0].risk.score > findings[1].risk.score);
    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "baseline.new")
    );
    assert!(
        findings[1]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "baseline.existing")
    );
}

#[test]
fn review_overlay_boosts_in_diff_findings() {
    let mut findings = vec![
        assessed_finding(
            "architecture.large-file",
            "src/changed.rs",
            Severity::Medium,
        ),
        assessed_finding(
            "architecture.large-file",
            "src/unchanged.rs",
            Severity::Medium,
        ),
    ];

    apply_review_overlay(&mut findings, &[true, false]);

    assert!(findings[0].risk.score > findings[1].risk.score);
    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "review.in-diff")
    );
}

#[test]
fn workspace_hotspot_overlay_adds_stable_learning_signal() {
    let mut findings = vec![
        assessed_finding(
            "security.secret-candidate",
            "packages/web/a.rs",
            Severity::High,
        ),
        assessed_finding(
            "security.secret-candidate",
            "packages/web/b.rs",
            Severity::High,
        ),
        assessed_finding(
            "security.secret-candidate",
            "packages/api/a.rs",
            Severity::High,
        ),
    ];
    findings[0].workspace_package = Some("web".to_string());
    findings[1].workspace_package = Some("web".to_string());
    findings[2].workspace_package = Some("api".to_string());

    apply_workspace_hotspot_overlay(&mut findings);

    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "workspace.hotspot")
    );
    assert!(
        findings[1]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "workspace.hotspot")
    );
    assert!(
        findings[2]
            .risk
            .signals
            .iter()
            .all(|signal| signal.id != "workspace.hotspot")
    );
}

#[test]
fn priority_labels_follow_score_thresholds() {
    let critical = assessed_finding(
        "security.private-key-candidate",
        "src/key.rs",
        Severity::Critical,
    );
    let low = assessed_finding("code-marker.todo", "src/lib.rs", Severity::Low);

    assert_eq!(critical.risk.priority, RiskPriority::P0);
    assert_eq!(low.risk.priority, RiskPriority::P3);
    assert_eq!(critical.risk.formula_version, FORMULA_VERSION);
}

#[test]
fn graph_overlay_boosts_hub_findings_above_leaf_findings() {
    let mut findings = vec![
        assessed_finding("code-quality.complex-file", "src/core.rs", Severity::Medium),
        assessed_finding("code-quality.complex-file", "src/leaf.rs", Severity::Medium),
    ];
    let graph = graph(&[
        ("src/a.rs", "src/core.rs"),
        ("src/b.rs", "src/core.rs"),
        ("src/c.rs", "src/core.rs"),
        ("src/core.rs", "src/leaf.rs"),
    ]);

    apply_graph_overlay(&mut findings, &graph);

    assert!(findings[0].risk.score > findings[1].risk.score);
    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "graph.hub")
    );
}

#[test]
fn blast_radius_overlay_boosts_impacted_files() {
    let mut findings = vec![
        assessed_finding(
            "architecture.large-file",
            "src/impacted.rs",
            Severity::Medium,
        ),
        assessed_finding("architecture.large-file", "src/other.rs", Severity::Medium),
    ];

    apply_blast_radius_overlay(
        &mut findings,
        Path::new("/repo"),
        &[PathBuf::from("src/impacted.rs")],
    );

    assert!(findings[0].risk.score > findings[1].risk.score);
    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "review.blast-radius")
    );
}

#[test]
fn cluster_overlay_marks_repeated_rule_scope_patterns() {
    let mut findings = vec![
        assessed_finding(
            "language.rust.panic-risk",
            "src/output/a.rs",
            Severity::Medium,
        ),
        assessed_finding(
            "language.rust.panic-risk",
            "src/output/b.rs",
            Severity::Medium,
        ),
        assessed_finding(
            "language.rust.panic-risk",
            "src/output/c.rs",
            Severity::Medium,
        ),
        assessed_finding(
            "language.rust.panic-risk",
            "src/core/d.rs",
            Severity::Medium,
        ),
    ];

    apply_cluster_overlay(&mut findings);

    assert!(
        findings[0]
            .risk
            .signals
            .iter()
            .any(|signal| signal.id == "cluster.repeated")
    );
    assert!(
        findings[3]
            .risk
            .signals
            .iter()
            .all(|signal| signal.id != "cluster.repeated")
    );
}

#[test]
fn knowledge_rule_adjustment_participates_in_risk_scoring() {
    let finding = finding("code-marker.todo", "src/lib.rs", Severity::Low);
    let file = file("src/lib.rs", Some("Rust"));

    let risk = assess_finding(&finding, Some(&file), RiskInputs::default());

    assert!(
        risk.signals
            .iter()
            .any(|signal| signal.id == "knowledge.todo-backlog")
    );
}

fn assessed_finding(rule_id: &str, path: &str, severity: Severity) -> Finding {
    let mut finding = finding(rule_id, path, severity);
    finding.risk = assess_finding(&finding, None, RiskInputs::default());
    finding
}

fn finding(rule_id: &str, path: &str, severity: Severity) -> Finding {
    Finding {
        id: String::new(),
        rule_id: rule_id.to_string(),
        title: "test finding".to_string(),
        description: "test finding".to_string(),
        recommendation: Finding::recommendation_for_rule_id(rule_id),
        category: if rule_id.starts_with("security.") {
            FindingCategory::Security
        } else {
            FindingCategory::CodeQuality
        },
        severity,
        confidence: Confidence::Medium,
        evidence: vec![Evidence {
            path: PathBuf::from(path),
            line_start: 1,
            line_end: None,
            snippet: String::new(),
        }],
        workspace_package: None,
        docs_url: None,
        risk: Default::default(),
    }
}

fn file(path: &str, language: Option<&str>) -> FileFacts {
    FileFacts {
        path: PathBuf::from(path),
        language: language.map(str::to_string),
        lines_of_code: 10,
        branch_count: 0,
        imports: Vec::new(),
        content: None,
        has_inline_tests: false,
    }
}

fn graph(edges: &[(&str, &str)]) -> CouplingGraph {
    let mut edge_map: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
    let mut nodes = BTreeSet::new();

    for (source, target) in edges {
        let source = PathBuf::from(source);
        let target = PathBuf::from(target);
        nodes.insert(source.clone());
        nodes.insert(target.clone());
        edge_map.entry(source).or_default().insert(target);
    }

    CouplingGraph {
        edges: edge_map,
        nodes,
    }
}