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 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}