git-slop 0.16.4

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use serde_json::{Value, json};

use super::common::{
    ORGANIZATION_ANALYSIS_STATUS, ORGANIZATION_ANALYSIS_VERSION, round6, stable_id,
};

pub(super) fn build_clusters(
    duplicate_relationships: &[Value],
) -> (Value, BTreeMap<String, Vec<String>>) {
    let mut adjacency: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    let mut relation_by_pair: BTreeMap<(String, String), String> = BTreeMap::new();
    for relationship in duplicate_relationships {
        let Some(source) = relationship["source_path"].as_str() else {
            continue;
        };
        let Some(target) = relationship["target_path"].as_str() else {
            continue;
        };
        adjacency
            .entry(source.to_string())
            .or_default()
            .insert(target.to_string());
        adjacency
            .entry(target.to_string())
            .or_default()
            .insert(source.to_string());
        relation_by_pair.insert(
            (source.to_string(), target.to_string()),
            relationship["id"].as_str().unwrap_or_default().to_string(),
        );
    }
    let mut visited = BTreeSet::new();
    let mut duplicate_sets = Vec::new();
    let mut candidates = Vec::new();
    let mut memberships: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for start in adjacency.keys() {
        if !visited.insert(start.clone()) {
            continue;
        }
        let mut stack = vec![start.clone()];
        let mut members = Vec::new();
        while let Some(path) = stack.pop() {
            members.push(path.clone());
            if let Some(neighbors) = adjacency.get(&path) {
                for neighbor in neighbors.iter().rev() {
                    if visited.insert(neighbor.clone()) {
                        stack.push(neighbor.clone());
                    }
                }
            }
        }
        members.sort();
        if members.len() < 2 {
            continue;
        }
        let member_refs: Vec<&str> = members.iter().map(String::as_str).collect();
        let id = stable_id("duplicate_set", &member_refs);
        let source_relationship_ids: Vec<String> = relation_by_pair
            .iter()
            .filter(|((left, right), _)| members.contains(left) && members.contains(right))
            .map(|(_, id)| id.clone())
            .collect();
        let roots: BTreeSet<String> = members
            .iter()
            .map(|path| crate::model::top_level_root(path))
            .collect();
        for path in &members {
            memberships
                .entry(path.clone())
                .or_default()
                .push(id.clone());
        }
        let duplicate_set = json!({
            "id": id,
            "kind": "duplicate_set",
            "candidate_type": "consolidate_duplicate_knowledge",
            "member_count": members.len(),
            "member_paths": members,
            "top_level_roots": roots,
            "evidence_score": round6((source_relationship_ids.len() as f64 / 5.0).min(1.0)),
            "source_relationship_ids": source_relationship_ids
        });
        let mut candidate = duplicate_set.clone();
        if let Some(object) = candidate.as_object_mut() {
            object.insert("kind".to_string(), json!("consolidation_candidate"));
        }
        duplicate_sets.push(duplicate_set);
        candidates.push(candidate);
    }
    duplicate_sets.sort_by(|left, right| {
        right["evidence_score"]
            .as_f64()
            .unwrap_or_default()
            .total_cmp(&left["evidence_score"].as_f64().unwrap_or_default())
            .then_with(|| left["id"].as_str().cmp(&right["id"].as_str()))
    });
    candidates.sort_by(|left, right| {
        right["evidence_score"]
            .as_f64()
            .unwrap_or_default()
            .total_cmp(&left["evidence_score"].as_f64().unwrap_or_default())
            .then_with(|| left["id"].as_str().cmp(&right["id"].as_str()))
    });
    (
        json!({
            "analysis_status": ORGANIZATION_ANALYSIS_STATUS,
            "analysis_version": ORGANIZATION_ANALYSIS_VERSION,
            "duplicate_sets": duplicate_sets,
            "scattered_concepts": [],
            "boundary_leakage_clusters": [],
            "consolidation_candidates": candidates
        }),
        memberships,
    )
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::build_clusters;
    use crate::overlays::common::{
        ORGANIZATION_ANALYSIS_STATUS, ORGANIZATION_ANALYSIS_VERSION, stable_id,
    };

    #[test]
    fn organization_sections_keep_canonical_metadata_keys_and_kinds() {
        let relationship_id = stable_id("duplicate_neighborhood", &["src/a.rs", "src/b.rs"]);
        let duplicate = json!({
            "id": relationship_id,
            "kind": "duplicate_neighborhood",
            "source_path": "src/a.rs",
            "target_path": "src/b.rs",
            "evidence_score": 1.0
        });
        let (clusters, _) = build_clusters(&[duplicate]);

        assert_eq!(clusters["analysis_status"], ORGANIZATION_ANALYSIS_STATUS);
        assert_eq!(clusters["analysis_version"], ORGANIZATION_ANALYSIS_VERSION);
        for key in [
            "duplicate_sets",
            "scattered_concepts",
            "boundary_leakage_clusters",
            "consolidation_candidates",
        ] {
            assert!(clusters[key].is_array(), "missing canonical array {key}");
        }
        assert_eq!(clusters["duplicate_sets"][0]["kind"], "duplicate_set");
        assert_eq!(
            clusters["consolidation_candidates"][0]["kind"],
            "consolidation_candidate"
        );
        assert_eq!(
            clusters["duplicate_sets"][0]["id"],
            clusters["consolidation_candidates"][0]["id"]
        );
    }
}