repopilot 0.11.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::baseline::diff::{BaselineStatus, FindingBaselineStatus};
use crate::baseline::key::normalized_relative_path;
use crate::findings::types::{Finding, Severity};
use crate::graph::{CouplingGraph, compute_metrics};
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};

use super::model::{
    FORMULA_VERSION, GraphImpact, RiskInputs, RiskSignal, clamp_score, priority_for_score, signal,
};

pub fn apply_baseline_overlay(
    findings: &mut [Finding],
    statuses: &[FindingBaselineStatus],
    root: &Path,
) {
    let status_by_key = statuses
        .iter()
        .map(|status| (status.key.as_str(), status.status))
        .collect::<HashMap<_, _>>();

    for finding in findings {
        let key = crate::baseline::key::stable_finding_key(finding, root);
        if let Some(status) = status_by_key.get(key.as_str()).copied() {
            apply_overlay_signal(
                finding,
                baseline_signal(status),
                RiskInputs {
                    baseline_status: Some(status),
                    ..RiskInputs::default()
                },
            );
        }
    }
}

pub fn apply_review_overlay(findings: &mut [Finding], in_diff: &[bool]) {
    for (finding, in_diff) in findings.iter_mut().zip(in_diff.iter().copied()) {
        if in_diff {
            apply_overlay_signal(
                finding,
                signal(
                    "review.in-diff",
                    "changed lines",
                    12,
                    "finding touches changed diff lines",
                ),
                RiskInputs {
                    in_diff: true,
                    ..RiskInputs::default()
                },
            );
        }
    }
}

pub fn apply_workspace_hotspot_overlay(findings: &mut [Finding]) {
    let hotspot_packages = workspace_hotspots(findings);
    if hotspot_packages.is_empty() {
        return;
    }

    for finding in findings {
        if finding
            .workspace_package
            .as_ref()
            .is_some_and(|package| hotspot_packages.contains(package))
        {
            apply_overlay_signal(
                finding,
                signal(
                    "workspace.hotspot",
                    "workspace hotspot",
                    5,
                    "workspace package has multiple high-risk findings",
                ),
                RiskInputs {
                    workspace_hotspot: true,
                    ..RiskInputs::default()
                },
            );
        }
    }
}

pub fn apply_graph_overlay(findings: &mut [Finding], graph: &CouplingGraph) {
    let impacts = graph_impacts(graph);
    if impacts.is_empty() {
        return;
    }

    for finding in findings {
        let Some(path) = finding_path_key(finding) else {
            continue;
        };
        let Some(impact) = impacts.get(path.as_str()).copied() else {
            continue;
        };
        let signal = match impact {
            GraphImpact::Hub => signal(
                "graph.hub",
                "dependency hub",
                8,
                "file has high fan-in or fan-out, so changes can ripple through the codebase",
            ),
            GraphImpact::Dependency => signal(
                "graph.dependency",
                "shared dependency",
                5,
                "file is imported by multiple other files",
            ),
        };
        apply_overlay_signal(
            finding,
            signal,
            RiskInputs {
                graph_impact: Some(impact),
                ..RiskInputs::default()
            },
        );
    }
}

pub fn apply_blast_radius_overlay(
    findings: &mut [Finding],
    repo_root: &Path,
    blast_radius: &[PathBuf],
) {
    let impacted = blast_radius
        .iter()
        .map(|path| path_key(path))
        .collect::<HashSet<_>>();
    if impacted.is_empty() {
        return;
    }

    for finding in findings {
        if finding
            .evidence
            .first()
            .map(|evidence| normalized_relative_path(&evidence.path, repo_root))
            .is_some_and(|path| impacted.contains(&path))
        {
            apply_overlay_signal(
                finding,
                signal(
                    "review.blast-radius",
                    "blast radius",
                    6,
                    "finding is in a file impacted by changed import dependencies",
                ),
                RiskInputs {
                    blast_radius: true,
                    ..RiskInputs::default()
                },
            );
        }
    }
}

pub fn apply_cluster_overlay(findings: &mut [Finding]) {
    let cluster_sizes = repeated_cluster_sizes(findings);
    if cluster_sizes.is_empty() {
        return;
    }

    for finding in findings {
        let Some(key) = finding_cluster_key(finding) else {
            continue;
        };
        let Some(size) = cluster_sizes.get(&key).copied().filter(|size| *size >= 3) else {
            continue;
        };
        apply_overlay_signal(
            finding,
            signal(
                "cluster.repeated",
                "repeated pattern",
                cluster_weight(size),
                "same rule appears repeatedly in the same repository area",
            ),
            RiskInputs {
                cluster_size: size,
                ..RiskInputs::default()
            },
        );
    }
}

pub(super) fn baseline_signal(status: BaselineStatus) -> RiskSignal {
    match status {
        BaselineStatus::New => signal(
            "baseline.new",
            "new finding",
            10,
            "new findings should be prioritized over accepted existing debt",
        ),
        BaselineStatus::Existing => signal(
            "baseline.existing",
            "existing finding",
            -8,
            "existing baseline findings are already accepted technical debt",
        ),
    }
}

fn apply_overlay_signal(finding: &mut Finding, signal: RiskSignal, _inputs: RiskInputs) {
    if finding
        .risk
        .signals
        .iter()
        .any(|existing| existing.id == signal.id)
    {
        return;
    }

    let score = finding.risk.score as i16 + signal.weight;
    finding.risk.signals.push(signal);
    finding.risk.score = clamp_score(score);
    finding.risk.priority = priority_for_score(finding.risk.score);
    finding.risk.formula_version = FORMULA_VERSION.to_string();
}

fn workspace_hotspots(findings: &[Finding]) -> HashSet<String> {
    let mut counts: HashMap<String, usize> = HashMap::new();
    for finding in findings {
        if finding.severity >= Severity::High
            && let Some(package) = &finding.workspace_package
        {
            *counts.entry(package.clone()).or_default() += 1;
        }
    }
    counts
        .into_iter()
        .filter_map(|(package, count)| (count > 1).then_some(package))
        .collect()
}

fn graph_impacts(graph: &CouplingGraph) -> HashMap<String, GraphImpact> {
    compute_metrics(graph)
        .into_iter()
        .filter_map(|metric| {
            let impact = if metric.fan_in >= 3 || metric.fan_out >= 8 {
                GraphImpact::Hub
            } else if metric.fan_in >= 2 {
                GraphImpact::Dependency
            } else {
                return None;
            };
            Some((path_key(&metric.path), impact))
        })
        .collect()
}

fn repeated_cluster_sizes(findings: &[Finding]) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for finding in findings {
        if let Some(key) = finding_cluster_key(finding) {
            *counts.entry(key).or_default() += 1;
        }
    }
    counts.retain(|_, count| *count >= 3);
    counts
}

fn finding_cluster_key(finding: &Finding) -> Option<String> {
    let path = finding
        .evidence
        .first()
        .map(|evidence| path_key(&evidence.path))?;
    Some(format!("{}:{}", finding.rule_id, cluster_scope(&path)))
}

fn finding_path_key(finding: &Finding) -> Option<String> {
    finding
        .evidence
        .first()
        .map(|evidence| path_key(&evidence.path))
}

fn cluster_scope(path: &str) -> String {
    let mut parts = path.split('/').filter(|part| !part.is_empty());
    match (parts.next(), parts.next()) {
        (Some(first), Some(second)) if second.contains('.') => first.to_string(),
        (Some(first), Some(second)) => format!("{first}/{second}"),
        (Some(first), None) => first.to_string(),
        _ => ".".to_string(),
    }
}

fn path_key(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::CurDir => None,
            Component::Normal(value) => Some(value.to_string_lossy().to_string()),
            Component::RootDir | Component::Prefix(_) | Component::ParentDir => {
                Some(component.as_os_str().to_string_lossy().to_string())
            }
        })
        .collect::<Vec<_>>()
        .join("/")
}

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