repopilot 0.18.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use super::*;

pub fn summarize_context_graph(
    graph: &RepoContextGraph,
    findings: &[Finding],
    changed_files: &[ChangedFile],
) -> ContextGraphSummary {
    let coupling_graph = graph.coupling_graph();
    let mut metrics = coupling_file_metrics(&coupling_graph);
    let node_by_path = graph
        .nodes
        .iter()
        .map(|node| (node.path.clone(), node))
        .collect::<HashMap<_, _>>();

    metrics.sort_by(|left, right| {
        right
            .fan_out
            .cmp(&left.fan_out)
            .then_with(|| right.fan_in.cmp(&left.fan_in))
            .then_with(|| left.path.cmp(&right.path))
    });
    let all_top_hubs = metrics
        .iter()
        .filter(|metric| metric.fan_out > 0)
        .map(|metric| metric_from_graph(metric, &node_by_path))
        .collect::<Vec<_>>();
    let top_hubs_truncated = all_top_hubs.len() > MAX_CONTEXT_GRAPH_METRICS;
    let top_hubs = all_top_hubs
        .into_iter()
        .take(MAX_CONTEXT_GRAPH_METRICS)
        .collect();

    metrics.sort_by(|left, right| {
        right
            .fan_in
            .cmp(&left.fan_in)
            .then_with(|| right.fan_out.cmp(&left.fan_out))
            .then_with(|| left.path.cmp(&right.path))
    });
    let all_top_dependencies = metrics
        .iter()
        .filter(|metric| metric.fan_in > 0)
        .map(|metric| metric_from_graph(metric, &node_by_path))
        .collect::<Vec<_>>();
    let top_dependencies_truncated = all_top_dependencies.len() > MAX_CONTEXT_GRAPH_METRICS;
    let top_dependencies = all_top_dependencies
        .into_iter()
        .take(MAX_CONTEXT_GRAPH_METRICS)
        .collect();

    let cycle_graph = without_rust_module_containment_edges(&coupling_graph);
    let mut cycles = detect_cycles_bounded(&cycle_graph, MAX_CONTEXT_GRAPH_CYCLES + 1);
    let cycles_truncated = cycles.len() > MAX_CONTEXT_GRAPH_CYCLES;
    cycles.truncate(MAX_CONTEXT_GRAPH_CYCLES);

    let (changed_blast_radius, blast_radius_truncated) =
        changed_blast_radius(&coupling_graph, changed_files);
    let (risky_clusters, risky_clusters_truncated) = risky_clusters(findings);
    let mut truncated = Vec::new();
    if top_hubs_truncated {
        truncated.push("top_hubs".to_string());
    }
    if top_dependencies_truncated {
        truncated.push("top_dependencies".to_string());
    }
    if cycles_truncated {
        truncated.push("cycles".to_string());
    }
    if blast_radius_truncated {
        truncated.push("changed_blast_radius".to_string());
    }
    if risky_clusters_truncated {
        truncated.push("risky_clusters".to_string());
    }

    ContextGraphSummary {
        files: graph.nodes.len(),
        import_edges: graph.edges.values().map(BTreeSet::len).sum(),
        top_hubs,
        top_dependencies,
        cycles,
        changed_blast_radius,
        risky_clusters,
        truncated,
    }
}

fn metric_from_graph(
    metric: &crate::graph::FileMetrics,
    node_by_path: &HashMap<PathBuf, &RepoContextNode>,
) -> ContextGraphFileMetric {
    let node = node_by_path.get(&metric.path);
    ContextGraphFileMetric {
        path: metric.path.clone(),
        fan_in: metric.fan_in,
        fan_out: metric.fan_out,
        instability: metric.instability,
        language: node.and_then(|node| node.language.clone()),
        roles: node.map(|node| node.roles.clone()).unwrap_or_default(),
    }
}

fn changed_blast_radius(
    graph: &CouplingGraph,
    changed_files: &[ChangedFile],
) -> (Vec<PathBuf>, bool) {
    if changed_files.is_empty() {
        return (Vec::new(), false);
    }

    let changed = changed_files
        .iter()
        .map(|file| file.path.clone())
        .collect::<HashSet<_>>();
    // Invert the dependency edges through the shared graph v2 one-hop
    // `direct_dependents`, then key the importer sets back by repository path so
    // the lookup below is unchanged.
    let (snapshot, path_by_id) = build_coupling_graph_snapshot(graph);
    let importers_by_target: BTreeMap<PathBuf, BTreeSet<PathBuf>> = direct_dependents(&snapshot)
        .into_iter()
        .filter_map(|(target_id, source_ids)| {
            let target = path_by_id.get(&target_id)?.clone();
            let sources = source_ids
                .iter()
                .filter_map(|id| path_by_id.get(id).cloned())
                .collect::<BTreeSet<_>>();
            Some((target, sources))
        })
        .collect();

    let mut impacted = BTreeSet::new();
    for path in &changed {
        if let Some(importers) = importers_by_target.get(path) {
            impacted.extend(
                importers
                    .iter()
                    .filter(|importer| !changed.contains(*importer))
                    .cloned(),
            );
        }
    }
    let truncated = impacted.len() > MAX_CONTEXT_GRAPH_BLAST_RADIUS;
    (
        impacted
            .into_iter()
            .take(MAX_CONTEXT_GRAPH_BLAST_RADIUS)
            .collect(),
        truncated,
    )
}

fn risky_clusters(findings: &[Finding]) -> (Vec<ContextRiskCluster>, bool) {
    let mut clusters: BTreeMap<(String, String), ContextRiskCluster> = BTreeMap::new();
    for finding in findings {
        let scope = finding
            .evidence
            .first()
            .map(|evidence| cluster_scope(&evidence.path))
            .unwrap_or_else(|| ".".to_string());
        let key = (finding.rule_id.clone(), scope.clone());
        let entry = clusters.entry(key).or_insert_with(|| ContextRiskCluster {
            rule_id: finding.rule_id.clone(),
            scope,
            count: 0,
            max_score: 0,
            priority: RiskPriority::P3,
        });
        entry.count += 1;
        entry.max_score = entry.max_score.max(finding.risk.score);
        if finding.risk.priority.rank() < entry.priority.rank() {
            entry.priority = finding.risk.priority;
        }
    }

    let mut clusters = clusters.into_values().collect::<Vec<_>>();
    clusters.sort_by(|left, right| {
        right
            .max_score
            .cmp(&left.max_score)
            .then_with(|| right.count.cmp(&left.count))
            .then_with(|| left.rule_id.cmp(&right.rule_id))
    });
    let truncated = clusters.len() > MAX_CONTEXT_GRAPH_RISKY_CLUSTERS;
    clusters.truncate(MAX_CONTEXT_GRAPH_RISKY_CLUSTERS);
    (clusters, truncated)
}

fn cluster_scope(path: &Path) -> String {
    let parts = path
        .components()
        .filter_map(|component| match component {
            std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()),
            _ => None,
        })
        .collect::<Vec<_>>();

    match (parts.first(), parts.get(1)) {
        (Some(first), Some(second)) if second.contains('.') => first.clone(),
        (Some(first), Some(second)) => format!("{first}/{second}"),
        (Some(first), None) => first.clone(),
        _ => ".".to_string(),
    }
}

pub(super) fn build_language_summary(
    languages: HashMap<String, usize>,
) -> Vec<crate::scan::types::LanguageSummary> {
    let mut languages = languages
        .into_iter()
        .map(
            |(name, files_analyzed)| crate::scan::types::LanguageSummary {
                name,
                files_analyzed,
            },
        )
        .collect::<Vec<_>>();
    languages.sort_by(|left, right| {
        right
            .files_analyzed
            .cmp(&left.files_analyzed)
            .then_with(|| left.name.cmp(&right.name))
    });
    languages
}

pub(super) fn directory_count(files: &[FileFacts]) -> usize {
    files
        .iter()
        .filter_map(|file| file.path.parent().map(Path::to_path_buf))
        .collect::<HashSet<_>>()
        .len()
}