git-slop 0.9.0

Deterministic repository health and maintenance-pressure analysis for humans and AI agents.
Documentation
use super::*;

fn plan_candidates(report: &Value, context: &Value) -> Vec<Value> {
    let mut slices = vec![anchor_plan_slice(report, context)];
    let selector_kind = string(value_at(context, &["selector", "kind"]));
    let record_type = string(value_at(context, &["target", "record_type"]));
    match selector_kind.as_str() {
        "path" => {
            let relationship_limit = if record_type == "folder" {
                usize::MAX
            } else {
                3
            };
            for relationship in array_at(context, &["supporting_relationships"])
                .iter()
                .take(relationship_limit)
            {
                if let Some(slice) = relationship_plan_slice(report, context, relationship) {
                    slices.push(slice);
                }
            }
            let cluster_limit = if record_type == "folder" {
                usize::MAX
            } else {
                2
            };
            let mut emitted = 0;
            for cluster in array_at(context, &["supporting_clusters"]) {
                if let Some(slice) = cluster_plan_slice(report, context, cluster) {
                    slices.push(slice);
                    emitted += 1;
                    if emitted >= cluster_limit {
                        break;
                    }
                }
            }
        }
        "cluster" => {
            let mut emitted = 0;
            for relationship in array_at(context, &["supporting_relationships"]) {
                if let Some(slice) = relationship_plan_slice(report, context, relationship) {
                    slices.push(slice);
                    emitted += 1;
                    if emitted >= 2 {
                        break;
                    }
                }
            }
        }
        _ => {
            let mut emitted = 0;
            for cluster in array_at(context, &["supporting_clusters"]) {
                if let Some(slice) = cluster_plan_slice(report, context, cluster) {
                    slices.push(slice);
                    emitted += 1;
                    if emitted >= 2 {
                        break;
                    }
                }
            }
        }
    }
    slices
}

fn merge_plan_slices(slices: Vec<Value>) -> Vec<Value> {
    let mut merged: Vec<Value> = Vec::new();
    for mut slice in slices {
        let scope = string_array(slice.get("scope_paths"));
        if let Some(existing) = merged
            .iter_mut()
            .find(|existing| string_array(existing.get("scope_paths")) == scope)
        {
            for key in [
                "out_of_scope_paths",
                "supporting_relationship_ids",
                "supporting_cluster_ids",
            ] {
                let values = unique_strings(
                    string_array(existing.get(key))
                        .into_iter()
                        .chain(string_array(slice.get(key))),
                );
                existing
                    .as_object_mut()
                    .expect("slice object")
                    .insert(key.to_string(), json!(values));
            }
            if usize_value(slice.get("_selector_class"))
                < usize_value(existing.get("_selector_class"))
            {
                for key in [
                    "_selector_class",
                    "id",
                    "title",
                    "why_this_slice",
                    "ranking_reason",
                ] {
                    existing.as_object_mut().expect("slice object").insert(
                        key.to_string(),
                        slice
                            .as_object_mut()
                            .expect("slice object")
                            .remove(key)
                            .unwrap_or(Value::Null),
                    );
                }
            }
        } else {
            merged.push(slice);
        }
    }
    merged
}

fn plan_top_score_sum(report: &Value, slice: &Value) -> f64 {
    let mut scores: Vec<f64> = string_array(slice.get("scope_paths"))
        .iter()
        .map(|path| record_slop_score(report, path))
        .collect();
    scores.sort_by(|left, right| cmp_f64_desc(*left, *right));
    scores.into_iter().take(3).sum()
}

fn rank_plan_slices(report: &Value, context: &Value, mut slices: Vec<Value>) -> Vec<Value> {
    let path_selector = string(value_at(context, &["selector", "kind"])) == "path";
    slices.sort_by(|left, right| {
        if path_selector {
            let left_anchor = usize_value(left.get("_selector_class")) != 0;
            let right_anchor = usize_value(right.get("_selector_class")) != 0;
            left_anchor
                .cmp(&right_anchor)
                .then_with(|| {
                    cmp_f64_desc(
                        plan_top_score_sum(report, left),
                        plan_top_score_sum(report, right),
                    )
                })
                .then_with(|| {
                    string_array(left.get("out_of_scope_paths"))
                        .len()
                        .cmp(&string_array(right.get("out_of_scope_paths")).len())
                })
                .then_with(|| {
                    string_array(left.get("scope_paths"))
                        .cmp(&string_array(right.get("scope_paths")))
                })
        } else {
            usize_value(left.get("_selector_class"))
                .cmp(&usize_value(right.get("_selector_class")))
                .then_with(|| {
                    string_array(right.get("supporting_relationship_ids"))
                        .len()
                        .cmp(&string_array(left.get("supporting_relationship_ids")).len())
                })
                .then_with(|| {
                    string_array(right.get("supporting_cluster_ids"))
                        .len()
                        .cmp(&string_array(left.get("supporting_cluster_ids")).len())
                })
                .then_with(|| {
                    string_array(left.get("out_of_scope_paths"))
                        .len()
                        .cmp(&string_array(right.get("out_of_scope_paths")).len())
                })
                .then_with(|| {
                    cmp_f64_desc(
                        plan_top_score_sum(report, left),
                        plan_top_score_sum(report, right),
                    )
                })
                .then_with(|| {
                    string_array(left.get("scope_paths"))
                        .cmp(&string_array(right.get("scope_paths")))
                })
        }
    });
    let mut kept: Vec<Value> = Vec::new();
    for slice in slices {
        let scope: BTreeSet<String> = string_array(slice.get("scope_paths")).into_iter().collect();
        let relationships: BTreeSet<String> =
            string_array(slice.get("supporting_relationship_ids"))
                .into_iter()
                .collect();
        let clusters: BTreeSet<String> = string_array(slice.get("supporting_cluster_ids"))
            .into_iter()
            .collect();
        let suppress = kept.iter().any(|existing| {
            let existing_scope: BTreeSet<String> = string_array(existing.get("scope_paths"))
                .into_iter()
                .collect();
            scope.is_subset(&existing_scope)
                && scope != existing_scope
                && relationships.is_subset(
                    &string_array(existing.get("supporting_relationship_ids"))
                        .into_iter()
                        .collect(),
                )
                && clusters.is_subset(
                    &string_array(existing.get("supporting_cluster_ids"))
                        .into_iter()
                        .collect(),
                )
        });
        if !suppress {
            kept.push(slice);
        }
    }
    kept
}

fn plan_evidence_summary(slice: &Value) -> String {
    let relationships = string_array(slice.get("supporting_relationship_ids"));
    let clusters = string_array(slice.get("supporting_cluster_ids"));
    let mut parts = Vec::new();
    if !relationships.is_empty() {
        parts.push(format!("{} relationship(s)", relationships.len()));
    }
    if !clusters.is_empty() {
        parts.push(format!("{} cluster(s)", clusters.len()));
    }
    if parts.is_empty() {
        parts.push("anchor detector evidence".to_string());
    }
    format!(
        "{} Evidence: {}. Scope: {}.",
        string(slice.get("why_this_slice")),
        parts.join(", "),
        {
            let scope = string_array(slice.get("scope_paths"));
            if scope.is_empty() {
                "none".to_string()
            } else {
                scope.join(", ")
            }
        },
    )
}

fn enrich_plan_slice(report: &Value, context: &Value, mut slice: Value) -> Value {
    slice
        .as_object_mut()
        .expect("slice object")
        .remove("_selector_class");
    let evidence_summary = plan_evidence_summary(&slice);
    let top_score = string_array(slice.get("scope_paths"))
        .iter()
        .map(|path| record_slop_score(report, path))
        .fold(0.0, f64::max);
    let priority = if top_score >= 75.0 {
        "Now"
    } else if top_score >= 40.0 {
        "Next"
    } else {
        "Later"
    };
    let backlog = json!({
        "mutation_policy": "preview_only",
        "proposed_issue_title": format!("Maintenance: {}", string(slice.get("title"))),
        "issue_type": "maintenance",
        "suggested_labels": ["maintenance"],
        "priority_hint": priority,
        "evidence_summary": evidence_summary,
        "acceptance_criteria": [
            "Review the scoped paths against the cited git-slop evidence.",
            "Keep changes bounded to the proposed scope unless new evidence is documented.",
            "Preserve detector score, check, and overlay semantics.",
        ],
        "source": {
            "command": "git slop plan",
            "selector": context.get("selector").cloned().unwrap_or(Value::Null),
            "report_schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
        },
    });
    slice
        .as_object_mut()
        .expect("slice object")
        .insert("evidence_summary".to_string(), json!(evidence_summary));
    slice
        .as_object_mut()
        .expect("slice object")
        .insert("backlog_handoff".to_string(), backlog);
    slice
}

pub fn plan_payload(report: &Value, selector: PlanSelector, max_slices: usize) -> Result<Value> {
    require_report_schema(report, "plan")?;
    if max_slices == 0 {
        bail!("--max-slices must be greater than zero.");
    }
    let context = match selector {
        PlanSelector::Path(path) => plan_context_for_path(report, &path)?,
        PlanSelector::Cluster(id) => plan_context_for_cluster(report, &id)?,
        PlanSelector::Relationship(id) => plan_context_for_relationship(report, &id)?,
    };
    let candidates = plan_candidates(report, &context);
    let merged = merge_plan_slices(candidates);
    let ranked = rank_plan_slices(report, &context, merged);
    let slices: Vec<Value> = ranked
        .into_iter()
        .take(max_slices)
        .map(|slice| enrich_plan_slice(report, &context, slice))
        .collect();
    let selector_kind = string(value_at(&context, &["selector", "kind"]));
    Ok(json!({
        "schema_version": PLAN_SCHEMA_VERSION,
        "report_schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
        "command": "plan",
        "selector": context.get("selector").cloned().unwrap_or(Value::Null),
        "target": context.get("target").cloned().unwrap_or(Value::Null),
        "proposed_slices": slices,
        "ranking_basis": {
            "anchor_first": true,
            "relationship_slices_before_cluster_slices": selector_kind != "path",
            "max_slice_files": MAX_SLICE_FILES,
            "secondary_sort": if selector_kind == "path" {
                "top-three-slop-score-sum, out-of-scope-count, path"
            } else {
                "relationship-count, cluster-count, out-of-scope-count, top-three-slop-score-sum, path"
            },
        },
        "backlog_handoff": {
            "mutation_policy": "preview_only",
            "candidate_count": slices.len(),
            "target_plugin_skill": "$project-management-workflows:plan-to-backlog-preview",
            "source_selector": context.get("selector").cloned().unwrap_or(Value::Null),
        },
        "boundary_note": PLAN_BOUNDARY_NOTE,
    }))
}

fn render_limited(values: &[String], limit: usize) -> String {
    if values.is_empty() {
        return "none".to_string();
    }
    let preview = values.iter().take(limit).cloned().collect::<Vec<_>>();
    let rendered = preview.join(", ");
    if values.len() > preview.len() {
        format!("{rendered} (+{} more)", values.len() - preview.len())
    } else {
        rendered
    }
}

pub fn render_plan_text(payload: &Value) -> String {
    let target = payload.get("target").unwrap_or(&Value::Null);
    let header = match string(target.get("kind")).as_str() {
        "path" => format!(
            "Plan: path {} [{}]",
            string(target.get("path")),
            string(target.get("record_type"))
        ),
        "cluster" => format!(
            "Plan: cluster {} [{}]",
            string(target.get("id")),
            string(target.get("cluster_kind"))
        ),
        _ => format!(
            "Plan: relationship {} [{}]",
            string(target.get("id")),
            string(target.get("relationship_kind"))
        ),
    };
    let mut lines = vec![header];
    for (index, slice) in array_at(payload, &["proposed_slices"]).iter().enumerate() {
        lines.extend([
            String::new(),
            format!("{}. {}", index + 1, string(slice.get("title"))),
            format!(
                "   scope: {}",
                render_limited(&string_array(slice.get("scope_paths")), usize::MAX)
            ),
            format!("   why: {}", string(slice.get("why_this_slice"))),
            format!(
                "   evidence_summary: {}",
                string(slice.get("evidence_summary"))
            ),
            format!(
                "   evidence: relationships={}; clusters={}",
                render_limited(&string_array(slice.get("supporting_relationship_ids")), 3),
                render_limited(&string_array(slice.get("supporting_cluster_ids")), 2),
            ),
            format!(
                "   backlog: {} priority={} policy=preview_only",
                string(value_at(
                    slice,
                    &["backlog_handoff", "proposed_issue_title"]
                )),
                string(value_at(slice, &["backlog_handoff", "priority_hint"])),
            ),
            format!(
                "   out_of_scope: {}",
                render_limited(&string_array(slice.get("out_of_scope_paths")), 5)
            ),
        ]);
    }
    lines.extend([String::new(), string(payload.get("boundary_note"))]);
    lines.join("\n")
}