Skip to main content

git_slop/
report_ops.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Result, anyhow, bail};
7use serde_json::{Map, Value, json};
8
9mod compare;
10mod explain;
11mod github;
12mod plan;
13mod sarif;
14
15pub use compare::{compare_payload, render_compare_text};
16pub use explain::{explain_payload, render_explain_text};
17pub use github::{health_json_payload, render_github_annotations, write_prompt_pack};
18pub use plan::{plan_payload, render_plan_text};
19pub use sarif::{render_json, sarif_payload};
20
21const REPORT_SCHEMA_VERSION: i64 = 4;
22const EXPLAIN_SCHEMA_VERSION: i64 = 2;
23const PLAN_SCHEMA_VERSION: i64 = 2;
24const COMPARE_SCHEMA_VERSION: i64 = 1;
25const SARIF_SCHEMA_VERSION: i64 = 1;
26const MAX_SLICE_FILES: usize = 5;
27
28const RELATIONSHIP_KEYS: [&str; 5] = [
29    "duplicate_neighborhoods",
30    "near_duplicate_neighborhoods",
31    "temporal_coupling_edges",
32    "lexical_affinity_edges",
33    "boundary_leakage_edges",
34];
35const CLUSTER_KEYS: [&str; 4] = [
36    "duplicate_sets",
37    "scattered_concepts",
38    "boundary_leakage_clusters",
39    "consolidation_candidates",
40];
41
42pub const EXPLAIN_BOUNDARY_NOTE: &str = "Interpretation boundary: this is structural evidence, not proof that an abstraction, boundary, or refactor is correct.";
43pub const PLAN_BOUNDARY_NOTE: &str = "Plan boundary: this is a bounded proposal only. It does not mutate code, GitHub, or detector truth, and it does not guarantee correctness or safety.";
44pub const COMPARE_BOUNDARY_NOTE: &str = "Compare boundary: this is a read-only comparison of two existing reports. It does not rerun the detector, imply causality, mutate repo state, or change detector scoring semantics.";
45pub const SARIF_BOUNDARY_NOTE: &str = "SARIF export boundary: this is a deterministic projection of existing git-slop report evidence. It does not rerun the detector, upload results, mutate code, or change detector scoring semantics.";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum OutputFormat {
49    Text,
50    Json,
51}
52
53#[derive(Debug, Clone)]
54pub enum ExplainSelector {
55    Path(String),
56    Cluster(String),
57    Relationship(String),
58    Top(usize),
59}
60
61#[derive(Debug, Clone)]
62pub enum PlanSelector {
63    Path(String),
64    Cluster(String),
65    Relationship(String),
66}
67
68fn array_at<'a>(value: &'a Value, path: &[&str]) -> &'a [Value] {
69    let mut current = value;
70    for key in path {
71        let Some(next) = current.get(*key) else {
72            return &[];
73        };
74        current = next;
75    }
76    current.as_array().map(Vec::as_slice).unwrap_or(&[])
77}
78
79fn value_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
80    let mut current = value;
81    for key in path {
82        current = current.get(*key)?;
83    }
84    Some(current)
85}
86
87fn string(value: Option<&Value>) -> String {
88    value
89        .and_then(Value::as_str)
90        .unwrap_or_default()
91        .to_string()
92}
93
94fn string_or(value: Option<&Value>, fallback: &str) -> String {
95    value
96        .and_then(Value::as_str)
97        .unwrap_or(fallback)
98        .to_string()
99}
100
101fn number(value: Option<&Value>) -> f64 {
102    value.and_then(Value::as_f64).unwrap_or(0.0)
103}
104
105fn round6(value: f64) -> f64 {
106    (value * 1_000_000.0).round() / 1_000_000.0
107}
108
109fn optional_string(value: Option<&Value>) -> Option<String> {
110    value.and_then(Value::as_str).map(ToOwned::to_owned)
111}
112
113fn integer(value: Option<&Value>) -> i64 {
114    value
115        .and_then(|value| {
116            value
117                .as_i64()
118                .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
119                .or_else(|| value.as_f64().map(|value| value as i64))
120        })
121        .unwrap_or(0)
122}
123
124fn usize_value(value: Option<&Value>) -> usize {
125    value
126        .and_then(Value::as_u64)
127        .and_then(|value| usize::try_from(value).ok())
128        .unwrap_or(0)
129}
130
131fn string_array(value: Option<&Value>) -> Vec<String> {
132    value
133        .and_then(Value::as_array)
134        .map(|items| {
135            items
136                .iter()
137                .filter_map(Value::as_str)
138                .map(ToOwned::to_owned)
139                .collect()
140        })
141        .unwrap_or_default()
142}
143
144fn cmp_f64_desc(left: f64, right: f64) -> Ordering {
145    right.partial_cmp(&left).unwrap_or(Ordering::Equal)
146}
147
148fn evidence_then_id(left: &Value, right: &Value) -> Ordering {
149    cmp_f64_desc(
150        number(left.get("evidence_score")),
151        number(right.get("evidence_score")),
152    )
153    .then_with(|| string(left.get("id")).cmp(&string(right.get("id"))))
154}
155
156fn normalized_path(path: &str) -> String {
157    let trimmed = path.trim();
158    if trimmed.is_empty() || trimmed == "." {
159        ".".to_string()
160    } else {
161        trimmed.trim_end_matches('/').to_string()
162    }
163}
164
165fn path_matches_folder(path: &str, folder: &str) -> bool {
166    folder == "." || path.starts_with(&format!("{}/", folder.trim_end_matches('/')))
167}
168
169fn report_schema(report: &Value) -> i64 {
170    integer(report.get("schema_version"))
171}
172
173fn require_report_schema(report: &Value, command: &str) -> Result<()> {
174    if report_schema(report) != REPORT_SCHEMA_VERSION {
175        bail!("git slop {command} requires report schema {REPORT_SCHEMA_VERSION}.");
176    }
177    Ok(())
178}
179
180fn relationship_sections(report: &Value, canonical_first: bool) -> &Value {
181    let top_level = report.get("relationships").filter(|value| {
182        value
183            .as_object()
184            .map(|value| !value.is_empty())
185            .unwrap_or(false)
186    });
187    let canonical = value_at(
188        report,
189        &["overlays", "organization_health", "relationships"],
190    )
191    .filter(|value| {
192        value
193            .as_object()
194            .map(|value| !value.is_empty())
195            .unwrap_or(false)
196    });
197    if canonical_first {
198        canonical.or(top_level).unwrap_or(&Value::Null)
199    } else {
200        top_level.or(canonical).unwrap_or(&Value::Null)
201    }
202}
203
204fn cluster_sections(report: &Value, canonical_first: bool) -> &Value {
205    let top_level = report.get("clusters").filter(|value| {
206        value
207            .as_object()
208            .map(|value| !value.is_empty())
209            .unwrap_or(false)
210    });
211    let canonical =
212        value_at(report, &["overlays", "organization_health", "clusters"]).filter(|value| {
213            value
214                .as_object()
215                .map(|value| !value.is_empty())
216                .unwrap_or(false)
217        });
218    if canonical_first {
219        canonical.or(top_level).unwrap_or(&Value::Null)
220    } else {
221        top_level.or(canonical).unwrap_or(&Value::Null)
222    }
223}
224
225fn all_relationships(report: &Value, canonical_first: bool) -> Vec<Value> {
226    let sections = relationship_sections(report, canonical_first);
227    let mut result = Vec::new();
228    for key in RELATIONSHIP_KEYS {
229        result.extend(array_at(sections, &[key]).iter().cloned());
230    }
231    result.sort_by(evidence_then_id);
232    dedupe_by_id(result)
233}
234
235fn all_clusters(report: &Value, canonical_first: bool) -> Vec<Value> {
236    let sections = cluster_sections(report, canonical_first);
237    let mut result = Vec::new();
238    for key in CLUSTER_KEYS {
239        result.extend(array_at(sections, &[key]).iter().cloned());
240    }
241    result.sort_by(evidence_then_id);
242    // A consolidation-candidate mirror may intentionally reuse its source
243    // cluster ID. Preserve memberships across cluster kinds; ID-only lookup
244    // remains deterministic because section order is stable.
245    result
246}
247
248fn dedupe_by_id(items: Vec<Value>) -> Vec<Value> {
249    let mut seen = BTreeSet::new();
250    items
251        .into_iter()
252        .filter(|item| {
253            let id = string(item.get("id"));
254            !id.is_empty() && seen.insert(id)
255        })
256        .collect()
257}
258
259fn matching_relationships(report: &Value, target: &str, folder: bool) -> Vec<Value> {
260    let mut result: Vec<Value> = all_relationships(report, false)
261        .into_iter()
262        .filter(|item| {
263            let source = string(item.get("source_path"));
264            let target_path = string(item.get("target_path"));
265            if folder {
266                path_matches_folder(&source, target) || path_matches_folder(&target_path, target)
267            } else {
268                source == target || target_path == target
269            }
270        })
271        .collect();
272    result.sort_by(evidence_then_id);
273    result
274}
275
276fn matching_clusters(report: &Value, target: &str, folder: bool) -> Vec<Value> {
277    let mut result: Vec<Value> = all_clusters(report, false)
278        .into_iter()
279        .filter(|item| {
280            string_array(item.get("member_paths")).iter().any(|member| {
281                if folder {
282                    path_matches_folder(member, target)
283                } else {
284                    member == target
285                }
286            })
287        })
288        .collect();
289    result.sort_by(evidence_then_id);
290    result
291}
292
293fn find_record(report: &Value, target: &str) -> Option<(Value, bool)> {
294    let target = normalized_path(target);
295    for record in array_at(report, &["files"]) {
296        if string(record.get("path")) == target {
297            return Some((record.clone(), true));
298        }
299    }
300    for record in array_at(report, &["folders"]) {
301        if string(record.get("path")) == target {
302            return Some((record.clone(), false));
303        }
304    }
305    None
306}
307
308pub fn show_payload(report: &Value, target: &str) -> Option<Value> {
309    let target = normalized_path(target);
310    let (record, is_file) = find_record(report, &target)?;
311    let mut payload = record.as_object()?.clone();
312    let overlays = payload.get("overlays").cloned().unwrap_or(Value::Null);
313    payload.insert(
314        "record_type".to_string(),
315        Value::String(if is_file { "file" } else { "folder" }.to_string()),
316    );
317    payload.insert(
318        "organization_health".to_string(),
319        overlays
320            .get("organization_health")
321            .cloned()
322            .unwrap_or(Value::Null),
323    );
324    payload.insert(
325        "strongest_relationships".to_string(),
326        Value::Array(
327            matching_relationships(report, &target, !is_file)
328                .into_iter()
329                .take(10)
330                .collect(),
331        ),
332    );
333    payload.insert(
334        "cluster_memberships".to_string(),
335        Value::Array(
336            matching_clusters(report, &target, !is_file)
337                .into_iter()
338                .take(10)
339                .collect(),
340        ),
341    );
342    Some(Value::Object(payload))
343}
344
345pub fn failing_records(
346    report: &Value,
347    fail_on_context_band: Option<&str>,
348    fail_on_slop_band: Option<&str>,
349) -> Vec<Value> {
350    fn context_rank(value: &str) -> i32 {
351        match value {
352            "compact" => 0,
353            "healthy" => 1,
354            "warning" => 2,
355            "critical" | "refactor_required" => 3,
356            _ => -1,
357        }
358    }
359    fn slop_rank(value: &str) -> i32 {
360        match value {
361            "low" => 0,
362            "moderate" => 1,
363            "high" => 2,
364            "critical" => 3,
365            _ => -1,
366        }
367    }
368    let mut failures: Vec<Value> = array_at(report, &["files"])
369        .iter()
370        .filter(|record| {
371            let context_failed = fail_on_context_band
372                .map(|threshold| {
373                    context_rank(&string(record.get("context_band"))) >= context_rank(threshold)
374                })
375                .unwrap_or(false);
376            let slop_failed = fail_on_slop_band
377                .map(|threshold| {
378                    slop_rank(&string(record.get("slop_band"))) >= slop_rank(threshold)
379                })
380                .unwrap_or(false);
381            context_failed || slop_failed
382        })
383        .cloned()
384        .collect();
385    failures.sort_by(|left, right| {
386        cmp_f64_desc(
387            number(left.get("slop_score")),
388            number(right.get("slop_score")),
389        )
390        .then_with(|| usize_value(right.get("tokens")).cmp(&usize_value(left.get("tokens"))))
391        .then_with(|| string(left.get("path")).cmp(&string(right.get("path"))))
392    });
393    failures
394}
395
396fn record_summary(record: Option<&Value>) -> Value {
397    let Some(record) = record else {
398        return Value::Null;
399    };
400    let mut result = Map::new();
401    for key in [
402        "path",
403        "slop_score",
404        "slop_band",
405        "context_band",
406        "reason_codes",
407    ] {
408        result.insert(
409            key.to_string(),
410            record.get(key).cloned().unwrap_or_else(|| match key {
411                "reason_codes" => Value::Array(Vec::new()),
412                _ => Value::Null,
413            }),
414        );
415    }
416    if let Some(costs) = record.get("costs") {
417        result.insert("costs".to_string(), costs.clone());
418    }
419    if let Some(overlays) = record.get("overlays") {
420        result.insert("overlays".to_string(), overlays.clone());
421    }
422    Value::Object(result)
423}
424
425fn resolved_record(report: &Value, path: &str) -> Option<Value> {
426    show_payload(report, path)
427}
428
429fn relationship_by_id(report: &Value, id: &str) -> Option<Value> {
430    all_relationships(report, true)
431        .into_iter()
432        .find(|item| string(item.get("id")) == id)
433}
434
435fn cluster_by_id(report: &Value, id: &str) -> Option<Value> {
436    all_clusters(report, true)
437        .into_iter()
438        .find(|item| string(item.get("id")) == id)
439}
440
441fn descendant_records(report: &Value, folder: &str) -> Vec<Value> {
442    let mut records: Vec<Value> = array_at(report, &["files"])
443        .iter()
444        .filter(|record| path_matches_folder(&string(record.get("path")), folder))
445        .cloned()
446        .collect();
447    records.sort_by(|left, right| {
448        cmp_f64_desc(
449            number(left.get("slop_score")),
450            number(right.get("slop_score")),
451        )
452        .then_with(|| string(left.get("path")).cmp(&string(right.get("path"))))
453    });
454    records
455}
456
457fn descendant_hotspots(report: &Value, folder: &str, limit: usize) -> Vec<Value> {
458    array_at(report, &["action_queue"])
459        .iter()
460        .filter(|record| path_matches_folder(&string(record.get("path")), folder))
461        .take(limit)
462        .cloned()
463        .collect()
464}
465
466fn overlay_value(record: &Value, overlay: &str, key: &str) -> f64 {
467    number(value_at(record, &["overlays", overlay, key]))
468}
469
470fn descendant_overlay_maxima(records: &[Value]) -> Value {
471    let maximum = |overlay: &str, key: &str| {
472        records
473            .iter()
474            .map(|record| overlay_value(record, overlay, key))
475            .fold(0.0, f64::max)
476    };
477    json!({
478        "organization_health": {
479            "duplication_pressure": maximum("organization_health", "duplication_pressure"),
480            "diffusion_pressure": maximum("organization_health", "diffusion_pressure"),
481            "coupling_pressure": maximum("organization_health", "coupling_pressure"),
482            "boundary_pressure": maximum("organization_health", "boundary_pressure"),
483        },
484        "verification": {"verification_gap": maximum("verification", "verification_gap")},
485        "navigation": {"navigation_pressure": maximum("navigation", "navigation_pressure")},
486        "blast_radius": {"blast_radius_pressure": maximum("blast_radius", "blast_radius_pressure")},
487        "stewardship": {"stewardship_pressure": maximum("stewardship", "stewardship_pressure")},
488        "semantic_drift": {"semantic_drift_pressure": maximum("semantic_drift", "semantic_drift_pressure")},
489    })
490}
491
492fn relationship_focus(item: &Value, anchors: &[String], folder: Option<&str>) -> (usize, usize) {
493    let endpoints = [
494        string(item.get("source_path")),
495        string(item.get("target_path")),
496    ];
497    let anchor_matches = endpoints
498        .iter()
499        .filter(|path| anchors.contains(path))
500        .count();
501    let folder_matches = folder
502        .map(|folder| {
503            endpoints
504                .iter()
505                .filter(|path| path_matches_folder(path, folder))
506                .count()
507        })
508        .unwrap_or(anchor_matches);
509    (folder_matches, anchor_matches)
510}
511
512fn rank_relationships(items: Vec<Value>, anchors: &[String], folder: Option<&str>) -> Vec<Value> {
513    let mut ranked: Vec<Value> = dedupe_by_id(items)
514        .into_iter()
515        .filter(|item| folder.is_none() || relationship_focus(item, anchors, folder).0 > 0)
516        .collect();
517    ranked.sort_by(|left, right| {
518        let left_focus = relationship_focus(left, anchors, folder);
519        let right_focus = relationship_focus(right, anchors, folder);
520        right_focus
521            .0
522            .cmp(&left_focus.0)
523            .then_with(|| right_focus.1.cmp(&left_focus.1))
524            .then_with(|| {
525                cmp_f64_desc(
526                    number(left.get("evidence_score")),
527                    number(right.get("evidence_score")),
528                )
529            })
530            .then_with(|| string(left.get("id")).cmp(&string(right.get("id"))))
531    });
532    ranked
533}
534
535fn cluster_focus(item: &Value, anchors: &[String], folder: Option<&str>) -> (usize, usize) {
536    let members = string_array(item.get("member_paths"));
537    let anchor_matches = members.iter().filter(|path| anchors.contains(path)).count();
538    let folder_matches = folder
539        .map(|folder| {
540            members
541                .iter()
542                .filter(|path| path_matches_folder(path, folder))
543                .count()
544        })
545        .unwrap_or(anchor_matches);
546    (folder_matches, anchor_matches)
547}
548
549fn rank_clusters(items: Vec<Value>, anchors: &[String], folder: Option<&str>) -> Vec<Value> {
550    let mut ranked: Vec<Value> = dedupe_by_id(items)
551        .into_iter()
552        .filter(|item| folder.is_none() || cluster_focus(item, anchors, folder).0 > 0)
553        .collect();
554    ranked.sort_by(|left, right| {
555        let left_focus = cluster_focus(left, anchors, folder);
556        let right_focus = cluster_focus(right, anchors, folder);
557        let left_count = usize_value(left.get("member_count"))
558            .max(string_array(left.get("member_paths")).len())
559            .max(1);
560        let right_count = usize_value(right.get("member_count"))
561            .max(string_array(right.get("member_paths")).len())
562            .max(1);
563        let left_density = left_focus.0 as f64 / left_count as f64;
564        let right_density = right_focus.0 as f64 / right_count as f64;
565        cmp_f64_desc(left_density, right_density)
566            .then_with(|| right_focus.1.cmp(&left_focus.1))
567            .then_with(|| left_count.cmp(&right_count))
568            .then_with(|| right_focus.0.cmp(&left_focus.0))
569            .then_with(|| {
570                string_array(left.get("top_level_roots"))
571                    .len()
572                    .cmp(&string_array(right.get("top_level_roots")).len())
573            })
574            .then_with(|| {
575                cmp_f64_desc(
576                    number(left.get("evidence_score")),
577                    number(right.get("evidence_score")),
578                )
579            })
580            .then_with(|| string(left.get("id")).cmp(&string(right.get("id"))))
581    });
582    ranked
583}
584
585fn shared_clusters_for_relationship(report: &Value, relationship: &Value) -> Vec<Value> {
586    let source = string(relationship.get("source_path"));
587    let target = string(relationship.get("target_path"));
588    let anchors = vec![source.clone(), target.clone()];
589    rank_clusters(
590        all_clusters(report, true)
591            .into_iter()
592            .filter(|cluster| {
593                let members = string_array(cluster.get("member_paths"));
594                members.contains(&source) && members.contains(&target)
595            })
596            .collect(),
597        &anchors,
598        None,
599    )
600}
601
602fn strongest_pressures(overlays: Option<&Value>, limit: usize) -> Vec<(String, f64)> {
603    let Some(overlays) = overlays.and_then(Value::as_object) else {
604        return Vec::new();
605    };
606    let specs = [
607        (
608            "organization.duplication",
609            "organization_health",
610            "duplication_pressure",
611        ),
612        (
613            "organization.diffusion",
614            "organization_health",
615            "diffusion_pressure",
616        ),
617        (
618            "organization.coupling",
619            "organization_health",
620            "coupling_pressure",
621        ),
622        (
623            "organization.boundary",
624            "organization_health",
625            "boundary_pressure",
626        ),
627        ("verification", "verification", "verification_gap"),
628        ("navigation", "navigation", "navigation_pressure"),
629        ("blast_radius", "blast_radius", "blast_radius_pressure"),
630        ("stewardship", "stewardship", "stewardship_pressure"),
631        (
632            "semantic_drift",
633            "semantic_drift",
634            "semantic_drift_pressure",
635        ),
636    ];
637    let mut values: Vec<(String, f64)> = specs
638        .into_iter()
639        .filter_map(|(label, family, key)| {
640            let value = overlays
641                .get(family)
642                .and_then(|value| value.as_object())
643                .and_then(|value| value.get(key))
644                .and_then(Value::as_f64)?;
645            Some((label.to_string(), value))
646        })
647        .filter(|(_, value)| *value > 0.0)
648        .collect();
649    values.sort_by(|left, right| cmp_f64_desc(left.1, right.1).then_with(|| left.0.cmp(&right.0)));
650    values.truncate(limit);
651    values
652}
653
654fn cost_evidence_summary(costs: Option<&Value>) -> Vec<String> {
655    let costs = costs.unwrap_or(&Value::Null);
656    let load = number(value_at(costs, &["load", "load_pressure"]));
657    let tokens = integer(value_at(costs, &["load", "file_token_count"]));
658    let volatility = number(value_at(costs, &["volatility", "volatility_pressure"]));
659    let commits = integer(value_at(costs, &["volatility", "commit_count_window"]));
660    let coordination = number(value_at(costs, &["coordination", "coordination_pressure"]));
661    let degree = integer(value_at(costs, &["coordination", "cochange_degree"]));
662    let mut values = vec![
663        (
664            load,
665            format!("load pressure {load:.3} from {tokens} file tokens"),
666        ),
667        (
668            volatility,
669            format!("volatility pressure {volatility:.3} from {commits} commits"),
670        ),
671        (
672            coordination,
673            format!("coordination pressure {coordination:.3} from degree {degree}"),
674        ),
675    ];
676    values.sort_by(|left, right| cmp_f64_desc(left.0, right.0).then_with(|| left.1.cmp(&right.1)));
677    values.into_iter().map(|(_, text)| text).take(3).collect()
678}
679
680fn evidence_summary(payload: &Value, mode: &str) -> Value {
681    let relationships: Vec<String> = array_at(payload, &["supporting_relationships"])
682        .iter()
683        .take(5)
684        .map(|item| string(item.get("id")))
685        .collect();
686    let clusters: Vec<String> = array_at(payload, &["supporting_clusters"])
687        .iter()
688        .take(5)
689        .map(|item| string(item.get("id")))
690        .collect();
691    let overlay_summary = payload.get("overlay_summary");
692    json!({
693        "detector_cost": cost_evidence_summary(payload.get("cost_summary")),
694        "strongest_overlays": strongest_pressures(overlay_summary, 3)
695            .into_iter()
696            .map(|(label, value)| format!("{label} pressure {value:.3}"))
697            .collect::<Vec<_>>(),
698        "supporting_evidence": {
699            "relationship_ids": relationships,
700            "cluster_ids": clusters,
701        },
702        "interpretation": format!("{mode} explanation is based on detector report evidence only; it does not prove correctness or require a refactor."),
703    })
704}
705
706fn base_explain_payload(report: &Value, selector: Value, target: Value) -> Value {
707    json!({
708        "schema_version": EXPLAIN_SCHEMA_VERSION,
709        "report_schema_version": report_schema(report),
710        "command": "explain",
711        "selector": selector,
712        "target": target,
713        "boundary_note": EXPLAIN_BOUNDARY_NOTE,
714    })
715}
716
717fn json_scalar_text(value: Option<&Value>) -> String {
718    match value {
719        None | Some(Value::Null) => "None".to_string(),
720        Some(Value::String(value)) => value.clone(),
721        Some(value) => value.to_string(),
722    }
723}