git-slop 0.9.6

Deterministic repository health and maintenance-pressure analysis for humans and AI agents.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde_json::{Value, json};

pub const DEFAULT_SLOP_GITIGNORE: &str = "/latest/\n/runs/\n/cache/\n";

#[derive(Debug, Clone)]
pub struct InitResult {
    pub config: &'static str,
    pub gitignore: &'static str,
}

pub fn slop_dir(repo_root: &Path) -> PathBuf {
    repo_root.join(".slop")
}

pub fn config_path(repo_root: &Path) -> PathBuf {
    slop_dir(repo_root).join("config.yaml")
}

pub fn latest_dir(repo_root: &Path) -> PathBuf {
    slop_dir(repo_root).join("latest")
}

pub fn runs_dir(repo_root: &Path) -> PathBuf {
    slop_dir(repo_root).join("runs")
}

pub fn cache_dir(repo_root: &Path) -> PathBuf {
    slop_dir(repo_root).join("cache")
}

pub fn default_config() -> Value {
    json!({
        "schema_version": 2,
        "inventory": {
            "ignore_globs": [
                "uv.lock", "poetry.lock", "Pipfile.lock", "package-lock.json",
                "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
                "Cargo.lock", "Gemfile.lock", "composer.lock", "Podfile.lock"
            ]
        },
        "tokenization": {
            "context_tokenizer_name": "cl100k_base",
            "context_bands": {
                "compact_max_tokens": 3072,
                "healthy_max_tokens": 8000,
                "warning_max_tokens": 10000
            }
        },
        "history": {
            "churn_window_days": 180,
            "age_half_life_days": 180,
            "follow_renames": false
        },
        "scoring": {
            "context_weight": 0.60,
            "age_weight": 0.20,
            "churn_weight": 0.20
        },
        "organization": {
            "candidate_file_limit": 500,
            "min_file_tokens": 300,
            "max_file_tokens": 50000,
            "shingle_size": 8,
            "window_step": 32,
            "min_similarity": 0.72,
            "max_pairs_per_file": 20,
            "min_cochange_support": 3,
            "min_coupling_lift": 2.0
        },
        "verification": {
            "test_path_markers": [
                "test/", "tests/", "spec/", "__tests__/", ".test.", ".spec."
            ]
        },
        "navigation": {"top_distinctive_terms": 5},
        "blast_radius": {},
        "stewardship": {"bot_name_markers": ["bot", "[bot]"]},
        "semantic_drift": {"top_term_limit": 25},
        "health": {
            "data_context_min_bytes": 262144,
            "folder_bands": {
                "compact_max_direct_tokens": 31999,
                "healthy_max_direct_tokens": 128000,
                "warning_max_direct_tokens": 256000,
                "warning_max_direct_files": 17,
                "refactor_required_max_direct_files": 37
            },
            "summary_top_files": 10,
            "summary_top_folders": 10
        },
        "check": {
            "fail_on_context_band": "critical",
            "fail_on_slop_band": "critical"
        }
    })
}

fn deep_merge(base: &mut Value, override_value: Value) {
    match (base, override_value) {
        (Value::Object(base_map), Value::Object(override_map)) => {
            for (key, value) in override_map {
                if let Some(existing) = base_map.get_mut(&key) {
                    deep_merge(existing, value);
                } else {
                    base_map.insert(key, value);
                }
            }
        }
        (base_slot, value) => *base_slot = value,
    }
}

fn normalize_legacy(mut payload: Value) -> Result<Value> {
    let Some(object) = payload.as_object_mut() else {
        bail!("config.yaml must decode to a mapping.");
    };
    let schema = object
        .get("schema_version")
        .and_then(Value::as_u64)
        .unwrap_or(1);
    if schema != 1 && schema != 2 {
        bail!("config.yaml must declare schema_version: 1 or schema_version: 2.");
    }
    if schema == 1 {
        object.insert("schema_version".into(), json!(2));
        let tokenizer_name = object
            .remove("tokenizer")
            .and_then(|item| item.get("name").cloned());
        let legacy_bands = object.remove("context_bands");
        if tokenizer_name.is_some() || legacy_bands.is_some() {
            let tokenization = object.entry("tokenization").or_insert_with(|| json!({}));
            let Some(tokenization) = tokenization.as_object_mut() else {
                bail!("tokenization must be a mapping.");
            };
            if let Some(name) = tokenizer_name {
                tokenization.entry("context_tokenizer_name").or_insert(name);
            }
            if let Some(bands) = legacy_bands {
                tokenization.entry("context_bands").or_insert(bands);
            }
        }
    }
    if let Some(check) = object.get_mut("check").and_then(Value::as_object_mut) {
        let legacy = check.remove("fail_on_priority_band");
        if !check.contains_key("fail_on_slop_band") {
            if let Some(legacy) = legacy {
                let mapped = match legacy.as_str().unwrap_or_default() {
                    "watchlist" => "low",
                    "needs_refactor" => "moderate",
                    "should_refactor" => "high",
                    "must_refactor" => "critical",
                    other => other,
                };
                check.insert("fail_on_slop_band".into(), json!(mapped));
            }
        }
    }
    Ok(payload)
}

fn add_legacy_aliases(mut payload: Value) -> Value {
    let tokenizer_name = pointer_str(&payload, "/tokenization/context_tokenizer_name")
        .unwrap_or("cl100k_base")
        .to_string();
    let bands = payload
        .pointer("/tokenization/context_bands")
        .cloned()
        .unwrap_or_else(|| json!({}));
    if let Some(object) = payload.as_object_mut() {
        object.insert("tokenizer".into(), json!({"name": tokenizer_name}));
        object.insert("context_bands".into(), bands);
    }
    payload
}

pub fn load(repo_root: &Path) -> Result<Value> {
    let path = config_path(repo_root);
    if !path.exists() {
        return Ok(add_legacy_aliases(default_config()));
    }
    let source =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let yaml: serde_yaml::Value =
        serde_yaml::from_str(&source).with_context(|| format!("invalid {}", path.display()))?;
    let override_value =
        serde_json::to_value(yaml).context("config.yaml contains unsupported YAML values")?;
    let override_value = normalize_legacy(override_value)?;
    let mut merged = default_config();
    deep_merge(&mut merged, override_value);
    Ok(add_legacy_aliases(merged))
}

pub fn ensure_state_dirs(repo_root: &Path) -> Result<()> {
    for path in [
        slop_dir(repo_root),
        latest_dir(repo_root),
        runs_dir(repo_root),
        cache_dir(repo_root),
    ] {
        fs::create_dir_all(&path)
            .with_context(|| format!("failed to create {}", path.display()))?;
    }
    Ok(())
}

pub fn initialize(repo_root: &Path, force: bool) -> Result<InitResult> {
    ensure_state_dirs(repo_root)?;
    let config_target = config_path(repo_root);
    let config_status = if force || !config_target.exists() {
        let yaml = serde_yaml::to_string(&default_config())?;
        fs::write(&config_target, yaml)
            .with_context(|| format!("failed to write {}", config_target.display()))?;
        "written"
    } else {
        "kept"
    };
    let gitignore_target = slop_dir(repo_root).join(".gitignore");
    let gitignore_status = if force || !gitignore_target.exists() {
        fs::write(&gitignore_target, DEFAULT_SLOP_GITIGNORE)
            .with_context(|| format!("failed to write {}", gitignore_target.display()))?;
        "written"
    } else {
        "kept"
    };
    Ok(InitResult {
        config: config_status,
        gitignore: gitignore_status,
    })
}

pub fn pointer_u64(value: &Value, pointer: &str, default: u64) -> u64 {
    value
        .pointer(pointer)
        .and_then(Value::as_u64)
        .unwrap_or(default)
}

pub fn pointer_f64(value: &Value, pointer: &str, default: f64) -> f64 {
    value
        .pointer(pointer)
        .and_then(Value::as_f64)
        .unwrap_or(default)
}

pub fn pointer_bool(value: &Value, pointer: &str, default: bool) -> bool {
    value
        .pointer(pointer)
        .and_then(Value::as_bool)
        .unwrap_or(default)
}

pub fn pointer_str<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
    value.pointer(pointer).and_then(Value::as_str)
}

pub fn pointer_strings(value: &Value, pointer: &str) -> Vec<String> {
    value
        .pointer(pointer)
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(Value::as_str)
        .map(ToOwned::to_owned)
        .collect()
}

#[cfg(test)]
mod tests {
    use std::fs;

    use serde_json::{Value, json};
    use tempfile::tempdir;

    use super::{config_path, default_config, load};

    fn load_payload(payload: Value) -> Value {
        let repository = tempdir().expect("temporary repository");
        let path = config_path(repository.path());
        fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
        fs::write(
            &path,
            serde_yaml::to_string(&payload).expect("serialize config"),
        )
        .expect("write config");
        load(repository.path()).expect("load config")
    }

    #[test]
    fn default_config_uses_the_schema_two_contract() {
        let config = default_config();

        assert_eq!(config["schema_version"], 2);
        assert_eq!(config["check"]["fail_on_slop_band"], "critical");
        assert!(config["check"].get("fail_on_priority_band").is_none());
        for section in ["tokenization", "organization", "verification"] {
            assert!(config.get(section).is_some(), "missing default {section}");
        }
    }

    #[test]
    fn schema_one_payload_defaults_and_aliases_are_normalized_to_schema_two() {
        let normalized = load_payload(json!({
            "tokenizer": {"name": "r50k_base"},
            "context_bands": {"warning_max_tokens": 9_000},
            "history": {"follow_renames": true}
        }));

        assert_eq!(normalized["schema_version"], 2);
        assert_eq!(
            normalized["tokenization"]["context_tokenizer_name"],
            "r50k_base"
        );
        assert_eq!(
            normalized["tokenization"]["context_bands"]["warning_max_tokens"],
            9_000
        );
        assert_eq!(
            normalized["tokenization"]["context_bands"]["compact_max_tokens"],
            3_072
        );
        assert_eq!(normalized["history"]["follow_renames"], true);
        assert_eq!(normalized["tokenizer"]["name"], "r50k_base");
        assert_eq!(normalized["context_bands"]["warning_max_tokens"], 9_000);
    }

    #[test]
    fn every_legacy_priority_band_maps_to_its_slop_band() {
        for (legacy, expected) in [
            ("watchlist", "low"),
            ("needs_refactor", "moderate"),
            ("should_refactor", "high"),
            ("must_refactor", "critical"),
        ] {
            let normalized = load_payload(json!({
                "schema_version": 2,
                "check": {"fail_on_priority_band": legacy}
            }));

            assert_eq!(normalized["check"]["fail_on_slop_band"], expected);
            assert!(
                normalized["check"].get("fail_on_priority_band").is_none(),
                "legacy key survived normalization for {legacy}"
            );
        }
    }

    #[test]
    fn new_slop_band_wins_and_the_legacy_key_is_always_removed() {
        let normalized = load_payload(json!({
            "schema_version": 2,
            "check": {
                "fail_on_priority_band": "must_refactor",
                "fail_on_slop_band": "moderate"
            }
        }));

        assert_eq!(normalized["check"]["fail_on_slop_band"], "moderate");
        assert!(normalized["check"].get("fail_on_priority_band").is_none());
    }
}