git-slop 0.16.4

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
fn run_check(repo_root: &Path, args: CheckArgs) -> Result<i32> {
    if args.details && !(1..=10_000).contains(&args.limit) {
        return Err(ClassifiedError::new(
            ErrorKind::Contract,
            "invalid_argument",
            "--limit must be between 1 and 10000",
        )
        .at("/limit")
        .with_details(json!({"flag": "--limit", "minimum": 1, "maximum": 10_000, "actual": args.limit}))
        .into());
    }
    let (loaded, _) = report_or_missing(repo_root, args.report.as_deref())?;
    let freshness = if args.require_current {
        Some(require_current_report(repo_root, &loaded)?)
    } else {
        None
    };
    let readiness = crate::report_ops::evaluate_report_readiness(
        &loaded,
        false,
        args.allow_incomplete_evidence,
    );
    if !readiness.comparison_ready {
        return Err(ClassifiedError::new(
            ErrorKind::Contract,
            "incomplete_evidence",
            "report is not enforcement-ready; rerun analysis with complete inputs or pass --allow-incomplete-evidence only for acknowledged evidence loss",
        )
        .at("/readiness")
        .with_details(readiness.as_json())
        .into());
    }
    let loaded_config = loaded
        .get("config")
        .cloned()
        .unwrap_or_else(config::default_config);
    let context_band = args
        .fail_on_context_band
        .map(ContextBand::as_str)
        .or_else(|| {
            loaded_config
                .pointer("/check/fail_on_context_band")
                .and_then(Value::as_str)
        })
        .unwrap_or("critical");
    let slop_band = args
        .fail_on_slop_band
        .map(SlopBand::as_str)
        .or_else(|| {
            loaded_config
                .pointer("/check/fail_on_slop_band")
                .and_then(Value::as_str)
        })
        .unwrap_or("critical");
    let failures = failing_records_in(
        &loaded,
        Some(context_band),
        Some(slop_band),
        args.include_folders,
    );
    if !matches!(args.format, CheckFormat::Text) {
        match args.format {
            CheckFormat::Json => {
                let mut payload = json!({
                    "schema_version": 1,
                    "command": "check",
                    "report": {"schema_version": loaded.get("schema_version"), "analyzer": loaded.get("analyzer"), "repo": loaded.get("repo"), "scope": loaded.get("scope")},
                    "boundary": {"context_band": context_band, "slop_band": slop_band},
                    "passed": failures.is_empty(),
                    "finding_count": failures.len(),
                    "details_included": args.details,
                    "gate_scope": if args.include_folders { "files_and_folders" } else { "files" },
                    "freshness": freshness,
                });
                if args.details {
                    let findings = failures
                        .iter()
                        .skip(args.offset)
                        .take(args.limit)
                        .cloned()
                        .collect::<Vec<_>>();
                    payload["findings"] = json!(findings);
                    payload["collection"] = json!({
                        "total": failures.len(),
                        "offset": args.offset,
                        "limit": args.limit,
                        "returned": payload["findings"].as_array().map(Vec::len).unwrap_or_default(),
                        "truncated": args.offset.saturating_add(args.limit) < failures.len(),
                    });
                }
                print_text(&render_json(&payload)?);
            }
            CheckFormat::Github => {
                for failure in &failures {
                    let path = crate::text::github_property_escape(
                        failure
                            .get("path")
                            .and_then(Value::as_str)
                            .unwrap_or_default(),
                    );
                    println!(
                        "::error file={}::Git Slop context={} slop={} score={}",
                        path,
                        failure
                            .get("context_band")
                            .and_then(Value::as_str)
                            .unwrap_or_default(),
                        failure
                            .get("slop_band")
                            .and_then(Value::as_str)
                            .unwrap_or_default(),
                        failure.get("slop_score").unwrap_or(&Value::Null)
                    );
                }
            }
            CheckFormat::Text => {}
        }
        return Ok(if failures.is_empty() || args.evaluate_only { 0 } else { 1 });
    }
    if failures.is_empty() {
        println!(
            "Check passed: no file records met or exceeded context={context_band} or slop={slop_band}."
        );
        return Ok(0);
    }
    println!(
        "Check failed: {} file records met or exceeded context={context_band} or slop={slop_band}.",
        failures.len()
    );
    for failure in failures.iter().take(10) {
        println!(
            "- {} (slop={}, context={}, slop_score={})",
            safe_terminal(
                failure
                    .get("path")
                    .and_then(Value::as_str)
                    .unwrap_or_default(),
            ),
            failure
                .get("slop_band")
                .and_then(Value::as_str)
                .unwrap_or_default(),
            failure
                .get("context_band")
                .and_then(Value::as_str)
                .unwrap_or_default(),
            failure
                .get("slop_score")
                .map(ToString::to_string)
                .unwrap_or_else(|| "null".to_string()),
        );
    }
    Ok(if args.evaluate_only { 0 } else { 1 })
}

fn bounded_compare_output(
    payload: &Value,
    detail: CompareDetail,
    top: usize,
    offset: usize,
    limit: usize,
    include_unchanged: bool,
) -> Result<Value> {
    if limit == 0 {
        anyhow::bail!("--limit must be greater than zero");
    }
    let mut bounded = payload.clone();
    let cap = match detail {
        CompareDetail::Summary => 0,
        CompareDetail::Top => top,
        CompareDetail::Full => limit,
    };
    let start = if matches!(detail, CompareDetail::Full) {
        offset
    } else {
        0
    };
    let mut pagination = serde_json::Map::new();
    for key in [
        "file_deltas",
        "folder_deltas",
        "queue_movement",
        "overlay_deltas",
        "regressions",
    ] {
        let all_values = payload
            .get(key)
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let changed_values = if key == "queue_movement" {
            all_values
                .iter()
                .filter(|item| {
                    item.get("status").and_then(Value::as_str) != Some("unchanged_position")
                })
                .cloned()
                .collect::<Vec<_>>()
        } else if matches!(key, "file_deltas" | "folder_deltas") {
            all_values
                .iter()
                .filter(|item| item.get("status").and_then(Value::as_str) != Some("unchanged"))
                .cloned()
                .collect::<Vec<_>>()
        } else {
            all_values.clone()
        };
        let values = if include_unchanged && matches!(key, "file_deltas" | "folder_deltas") {
            all_values.clone()
        } else {
            changed_values.clone()
        };
        let returned = values
            .iter()
            .skip(start)
            .take(cap)
            .cloned()
            .collect::<Vec<_>>();
        pagination.insert(
            key.to_string(),
            json!({
                "total": values.len(),
                "changed_total": changed_values.len(),
                "all_total": all_values.len(),
                "offset": start,
                "limit": cap,
                "returned": returned.len(),
                "has_more": start.saturating_add(returned.len()) < values.len()
            }),
        );
        bounded[key] = json!(returned);
    }
    bounded["detail"] = json!(match detail {
        CompareDetail::Summary => "summary",
        CompareDetail::Top => "top",
        CompareDetail::Full => "full",
    });
    bounded["pagination"] = Value::Object(pagination);
    bounded["include_unchanged"] = json!(include_unchanged);
    Ok(bounded)
}

fn render_compare_ndjson(payload: &Value) -> Result<String> {
    let mut lines = vec![serde_json::to_string(&json!({
        "record_type": "summary",
        "schema_version": payload.get("schema_version"),
        "stream": {
            "schema": "schemas/compare-ndjson-1.json",
            "record_types": ["summary", "file_delta", "folder_delta", "queue_movement", "overlay_delta", "regression"]
        },
        "summary": payload.get("summary"),
        "pagination": payload.get("pagination"),
        "baseline_status": payload.get("baseline_status")
    }))?];
    for (key, record_type) in [
        ("file_deltas", "file_delta"),
        ("folder_deltas", "folder_delta"),
        ("queue_movement", "queue_movement"),
        ("overlay_deltas", "overlay_delta"),
        ("regressions", "regression"),
    ] {
        for record in payload
            .get(key)
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
        {
            lines.push(serde_json::to_string(
                &json!({"record_type": record_type, "record": record}),
            )?);
        }
    }
    Ok(format!("{}\n", lines.join("\n")))
}

#[cfg(test)]
mod compare_pagination_tests {
    use super::*;

    #[test]
    fn unchanged_records_are_opt_in_and_do_not_create_phantom_pages() {
        let payload = json!({
            "file_deltas": [
                {"path":"same.rs","status":"unchanged"},
                {"path":"changed.rs","status":"changed"}
            ],
            "folder_deltas": [{"path":".","status":"unchanged"}],
            "queue_movement": [], "overlay_deltas": [], "regressions": []
        });
        let bounded = bounded_compare_output(&payload, CompareDetail::Top, 10, 0, 1000, false)
            .expect("bounded compare");
        assert_eq!(bounded["file_deltas"].as_array().map(Vec::len), Some(1));
        assert_eq!(bounded["folder_deltas"].as_array().map(Vec::len), Some(0));
        assert_eq!(bounded["pagination"]["file_deltas"]["all_total"], 2);
        assert_eq!(bounded["pagination"]["file_deltas"]["changed_total"], 1);
        assert_eq!(bounded["pagination"]["folder_deltas"]["has_more"], false);

        let complete = bounded_compare_output(&payload, CompareDetail::Top, 10, 0, 1000, true)
            .expect("complete compare");
        assert_eq!(complete["file_deltas"].as_array().map(Vec::len), Some(2));
        assert_eq!(complete["pagination"]["file_deltas"]["changed_total"], 1);
    }
}

#[cfg(test)]
mod ndjson_tests {
    use super::*;

    #[test]
    fn compare_ndjson_emits_one_complete_json_value_per_physical_line() {
        let payload = json!({
            "schema_version": 1,
            "summary": {},
            "pagination": {},
            "baseline_status": "compatible",
            "file_deltas": [{"path": "src/lib.rs", "nested": {"value": true}}],
            "folder_deltas": [],
            "queue_movement": [],
            "overlay_deltas": [],
            "regressions": []
        });
        let rendered = render_compare_ndjson(&payload).expect("ndjson");
        let lines = rendered.lines().collect::<Vec<_>>();
        assert_eq!(lines.len(), 2);
        for line in lines {
            assert!(!line.contains('\n'));
            serde_json::from_str::<Value>(line).expect("single-line JSON record");
        }
    }
}