repopilot 0.11.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::findings::types::{Confidence, Finding, FindingCategory, Severity};
use crate::knowledge::decision::decide_for_file;
use crate::scan::facts::{FileFacts, ScanFacts};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use super::context::add_file_context_signals;
use super::model::{
    GraphImpact, RiskAssessment, RiskInputs, RiskSignal, clamp_score, push_adjustment, signal,
};
use super::overlays::baseline_signal;

pub fn assess_findings(findings: &mut [Finding], facts: &ScanFacts) {
    let files = file_index(facts);
    for finding in findings {
        let file = finding_file(finding, facts.root_path.as_path(), &files);
        finding.risk = assess_finding(finding, file, RiskInputs::default());
    }
}

pub fn assess_finding(
    finding: &Finding,
    file: Option<&FileFacts>,
    inputs: RiskInputs,
) -> RiskAssessment {
    let base = severity_base_score(finding.severity);
    let confidence_delta = confidence_delta(base, finding.confidence);
    let mut score = base as i16 + confidence_delta;
    let mut signals = vec![severity_signal(finding.severity, base)];

    if confidence_delta != 0 {
        signals.push(confidence_signal(finding.confidence, confidence_delta));
    }

    if finding.category == FindingCategory::Security {
        push_adjustment(
            &mut score,
            &mut signals,
            "category.security",
            "security finding",
            12,
            "security findings usually have higher remediation priority",
        );
    }

    if let Some(file) = file {
        add_knowledge_rule_signal(finding, file, &mut score, &mut signals);
        add_file_context_signals(file, &mut score, &mut signals);
    }

    if let Some(status) = inputs.baseline_status {
        let signal = baseline_signal(status);
        super::model::push_signal(&mut score, &mut signals, signal);
    }

    if inputs.in_diff {
        push_adjustment(
            &mut score,
            &mut signals,
            "review.in-diff",
            "changed lines",
            12,
            "finding touches changed diff lines",
        );
    }

    if inputs.workspace_hotspot {
        push_adjustment(
            &mut score,
            &mut signals,
            "workspace.hotspot",
            "workspace hotspot",
            5,
            "workspace package has multiple high-risk findings",
        );
    }

    if let Some(impact) = inputs.graph_impact {
        add_graph_signal(impact, &mut score, &mut signals);
    }

    if inputs.blast_radius {
        push_adjustment(
            &mut score,
            &mut signals,
            "review.blast-radius",
            "blast radius",
            6,
            "finding is in a file impacted by changed import dependencies",
        );
    }

    if inputs.cluster_size >= 3 {
        let weight = cluster_weight(inputs.cluster_size);
        push_adjustment(
            &mut score,
            &mut signals,
            "cluster.repeated",
            "repeated pattern",
            weight,
            "same rule appears repeatedly in the same repository area",
        );
    }

    RiskAssessment::new(clamp_score(score), signals)
}

fn add_knowledge_rule_signal(
    finding: &Finding,
    file: &FileFacts,
    score: &mut i16,
    signals: &mut Vec<RiskSignal>,
) {
    let decision = decide_for_file(&finding.rule_id, file, finding.severity, None);
    let Some(rule_signal) = decision.risk_signal else {
        return;
    };

    push_adjustment(
        score,
        signals,
        rule_signal.id.as_str(),
        rule_signal.label.as_str(),
        rule_signal.weight,
        rule_signal.reason.as_str(),
    );
}

fn add_graph_signal(impact: GraphImpact, score: &mut i16, signals: &mut Vec<RiskSignal>) {
    match impact {
        GraphImpact::Hub => push_adjustment(
            score,
            signals,
            "graph.hub",
            "dependency hub",
            8,
            "file has high fan-in or fan-out, so changes can ripple through the codebase",
        ),
        GraphImpact::Dependency => push_adjustment(
            score,
            signals,
            "graph.dependency",
            "shared dependency",
            5,
            "file is imported by multiple other files",
        ),
    }
}

fn cluster_weight(size: usize) -> i16 {
    match size {
        0..=2 => 0,
        3..=5 => 3,
        6..=15 => 5,
        _ => 7,
    }
}

fn severity_base_score(severity: Severity) -> u8 {
    match severity {
        Severity::Critical => 95,
        Severity::High => 75,
        Severity::Medium => 45,
        Severity::Low => 20,
        Severity::Info => 5,
    }
}

fn severity_signal(severity: Severity, score: u8) -> RiskSignal {
    signal(
        &format!("severity.{}", severity.lowercase_label()),
        &format!("{} severity", severity.label()),
        score as i16,
        "base score from rule severity",
    )
}

fn confidence_delta(base: u8, confidence: Confidence) -> i16 {
    let multiplier = match confidence {
        Confidence::High => 1.10,
        Confidence::Medium => 1.00,
        Confidence::Low => 0.80,
    };
    ((base as f64 * multiplier).round() as i16) - base as i16
}

fn confidence_signal(confidence: Confidence, weight: i16) -> RiskSignal {
    signal(
        &format!("confidence.{}", confidence.lowercase_label()),
        &format!("{} confidence", confidence.label()),
        weight,
        "confidence adjusts certainty without changing rule severity",
    )
}

fn file_index(facts: &ScanFacts) -> HashMap<PathBuf, &FileFacts> {
    let mut files = HashMap::new();
    for file in &facts.files {
        files.insert(file.path.clone(), file);
        if let Ok(relative) = file.path.strip_prefix(&facts.root_path) {
            files.insert(relative.to_path_buf(), file);
        }
    }
    files
}

fn finding_file<'a>(
    finding: &Finding,
    root: &Path,
    files: &'a HashMap<PathBuf, &'a FileFacts>,
) -> Option<&'a FileFacts> {
    let evidence = finding.evidence.first()?;
    files.get(&evidence.path).copied().or_else(|| {
        evidence
            .path
            .strip_prefix(root)
            .ok()
            .and_then(|relative| files.get(relative).copied())
    })
}