git-slop 0.16.0

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use chrono::Utc;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};

use crate::policy::CompiledPolicySet;
use crate::text::visible_controls;

use super::{ADVICE_SCHEMA_VERSION, ProviderResult, ValidatedResponse};

pub struct AdviceRun {
    pub artifact: Value,
    pub markdown: String,
}

pub struct AdviceTimings {
    pub context_elapsed_ms: u128,
    pub provider_elapsed_ms: u128,
    pub validation_elapsed_ms: u128,
    pub time_to_validated_artifact_ms: u128,
}

fn sha256(bytes: impl AsRef<[u8]>) -> String {
    hex::encode(Sha256::digest(bytes.as_ref()))
}

impl AdviceRun {
    pub fn new(
        input: &Value,
        policies: &CompiledPolicySet,
        provider: &ProviderResult,
        validated: &ValidatedResponse,
        timings: AdviceTimings,
    ) -> Result<Self> {
        let generated_at = Utc::now().to_rfc3339();
        let response_digest = sha256(serde_json::to_vec(&provider.response)?);
        let artifact = json!({
            "schema_version": ADVICE_SCHEMA_VERSION,
            "command": "advise",
            "generated_at": generated_at,
            "report": input.get("report").cloned().unwrap_or(Value::Null),
            "selector": input.get("selector").cloned().unwrap_or(Value::Null),
            "candidate_ids": input.pointer("/reference_index/candidates").cloned().unwrap_or_else(|| json!([])),
            "context": {
                "builder_version": input.get("context_builder_version").cloned().unwrap_or(Value::Null),
                "digest": input.get("context_digest").cloned().unwrap_or(Value::Null),
                "limits": input.get("limits").cloned().unwrap_or(Value::Null),
                "missing_evidence": input.get("missing_evidence").cloned().unwrap_or_else(|| json!([])),
                "reference_index": input.get("reference_index").cloned().unwrap_or(Value::Null),
            },
            "policies": {
                "resolution_digest": &policies.resolution_digest,
                "packs": &policies.packs,
                "conflicts": &policies.conflicts,
            },
            "provider": &provider.metadata,
            "timing": {
                "context_elapsed_ms": timings.context_elapsed_ms,
                "provider_elapsed_ms": timings.provider_elapsed_ms,
                "validation_elapsed_ms": timings.validation_elapsed_ms,
                "time_to_validated_artifact_ms": timings.time_to_validated_artifact_ms
            },
            "response_sha256": response_digest,
            "evaluation": validated,
            "validation": {
                "status": "valid",
                "aggregate_recomputed": true,
                "references_validated": true,
                "warnings": &validated.warnings,
            },
            "boundary": "Policy-guided advice is non-mutating and advisory. It cannot rewrite detector truth or change git slop check results."
        });
        let schema: Value = serde_json::from_str(include_str!("../../schemas/advice-1.json"))?;
        let validator = jsonschema::draft202012::options()
            .should_validate_formats(true)
            .build(&schema)
            .context("embedded advice artifact schema is invalid")?;
        if let Some(error) = validator.iter_errors(&artifact).next() {
            bail!(
                "generated advice artifact does not match schema v{ADVICE_SCHEMA_VERSION} at {}: {}",
                error.instance_path(),
                error
            );
        }
        let markdown = render_advice_markdown(&artifact);
        Ok(Self { artifact, markdown })
    }
}

fn string(value: Option<&Value>, fallback: &str) -> String {
    value
        .and_then(Value::as_str)
        .map(visible_controls)
        .unwrap_or_else(|| fallback.to_string())
}

fn strings(value: Option<&Value>) -> Vec<String> {
    value
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(Value::as_str)
        .map(visible_controls)
        .collect()
}

pub fn render_advice_markdown(artifact: &Value) -> String {
    let mut lines = vec![
        "# Git Slop policy-guided advice".to_string(),
        String::new(),
        format!(
            "> Aggregate verdict: **{}**. This output is advisory and cannot change detector truth or repository state.",
            string(artifact.pointer("/evaluation/aggregate_verdict"), "unknown")
        ),
        String::new(),
        string(
            artifact.pointer("/evaluation/summary"),
            "No summary supplied.",
        ),
        String::new(),
        "## Provenance".to_string(),
        String::new(),
        format!(
            "- Report: `{}`",
            string(artifact.pointer("/report/sha256"), "unknown")
        ),
        format!(
            "- Revision: `{}`",
            string(artifact.pointer("/report/head_sha"), "unknown")
        ),
        format!(
            "- Context: `{}`",
            string(artifact.pointer("/context/digest"), "unknown")
        ),
        format!(
            "- Policies: `{}`",
            string(artifact.pointer("/policies/resolution_digest"), "unknown")
        ),
        format!(
            "- Provider: `{}`",
            string(artifact.pointer("/provider/provider"), "unknown")
        ),
        format!(
            "- Model: `{}`",
            string(artifact.pointer("/provider/model"), "unknown")
        ),
        format!(
            "- Endpoint class: `{}`",
            string(
                artifact.pointer("/provider/endpoint_classification"),
                "unknown"
            )
        ),
    ];
    for candidate in artifact
        .pointer("/evaluation/candidate_evaluations")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        lines.extend([
            String::new(),
            format!(
                "## {} — {}",
                string(candidate.get("candidate_id"), "candidate"),
                string(candidate.get("aggregate_verdict"), "unknown")
            ),
            String::new(),
            string(candidate.get("rationale"), "No rationale supplied."),
            String::new(),
            "### Rule evaluations".to_string(),
            String::new(),
        ]);
        for rule in candidate
            .get("rule_evaluations")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
        {
            lines.push(format!(
                "- **{} — {}:** {}",
                string(rule.get("rule_id"), "rule"),
                string(rule.get("verdict"), "unknown"),
                string(rule.get("rationale"), "No rationale supplied.")
            ));
        }
        let revisions = strings(candidate.get("requested_revisions"));
        if !revisions.is_empty() {
            lines.extend([
                String::new(),
                "### Requested revisions".to_string(),
                String::new(),
            ]);
            lines.extend(revisions.into_iter().map(|value| format!("- {value}")));
        }
        if let Some(next) = candidate
            .get("recommended_next_step")
            .and_then(Value::as_str)
        {
            lines.extend([
                String::new(),
                "### Recommended next step".to_string(),
                String::new(),
                visible_controls(next),
            ]);
        }
        let missing = strings(candidate.get("missing_evidence"));
        if !missing.is_empty() {
            lines.extend([
                String::new(),
                "### Missing evidence".to_string(),
                String::new(),
            ]);
            lines.extend(missing.into_iter().map(|value| format!("- {value}")));
        }
    }
    let warnings = strings(artifact.pointer("/validation/warnings"));
    if !warnings.is_empty() {
        lines.extend([
            String::new(),
            "## Validation warnings".to_string(),
            String::new(),
        ]);
        lines.extend(warnings.into_iter().map(|value| format!("- {value}")));
    }
    lines.extend([
        String::new(),
        "---".to_string(),
        String::new(),
        string(artifact.get("boundary"), "Advice is advisory only."),
    ]);
    format!("{}\n", lines.join("\n"))
}

fn write_pair(directory: &Path, artifact: &Value, markdown: &str) -> Result<()> {
    fs::create_dir_all(directory)?;
    fs::write(
        directory.join("advice.json"),
        serde_json::to_string_pretty(artifact)? + "\n",
    )?;
    fs::write(directory.join("advice.md"), markdown)?;
    Ok(())
}

pub fn write_artifacts(repo_root: &Path, run: &AdviceRun) -> Result<(PathBuf, PathBuf)> {
    let root = crate::config::active_state_dir(repo_root)?.join("advice");
    let digest = run
        .artifact
        .get("response_sha256")
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow::anyhow!("advice artifact is missing response_sha256"))?;
    let generated = run
        .artifact
        .get("generated_at")
        .and_then(Value::as_str)
        .unwrap_or("unknown")
        .replace([':', '+'], "-");
    let run_id = format!("{generated}-{}", &digest[..16]);
    let runs = root.join("runs");
    fs::create_dir_all(&runs)?;
    let run_dir = runs.join(&run_id);
    if run_dir.exists() {
        bail!("advice run already exists: {}", run_dir.display());
    }
    let temporary_run = runs.join(format!(".{run_id}-{}.tmp", std::process::id()));
    write_pair(&temporary_run, &run.artifact, &run.markdown)?;
    fs::rename(&temporary_run, &run_dir)?;

    let latest = root.join("latest");
    let temporary_latest = root.join(format!(".latest-{}.tmp", std::process::id()));
    if temporary_latest.exists() {
        fs::remove_dir_all(&temporary_latest)?;
    }
    write_pair(&temporary_latest, &run.artifact, &run.markdown)?;
    let backup = root.join(format!(".latest-{}.backup", std::process::id()));
    if latest.exists() {
        fs::rename(&latest, &backup)?;
    }
    if let Err(error) = fs::rename(&temporary_latest, &latest) {
        let _ = fs::remove_dir_all(&temporary_latest);
        if backup.exists() {
            let _ = fs::rename(&backup, &latest);
        }
        return Err(error.into());
    }
    if backup.exists() {
        fs::remove_dir_all(backup)?;
    }
    Ok((latest.join("advice.json"), latest.join("advice.md")))
}

pub fn load_and_validate_artifact(path: &Path, report: &Value) -> Result<Value> {
    let artifact: Value = serde_json::from_slice(&fs::read(path)?)
        .with_context(|| format!("unable to parse advice artifact {}", path.display()))?;
    let schema: Value = serde_json::from_str(include_str!("../../schemas/advice-1.json"))?;
    let validator = jsonschema::draft202012::options()
        .should_validate_formats(true)
        .build(&schema)
        .context("embedded advice artifact schema is invalid")?;
    if let Some(error) = validator.iter_errors(&artifact).next() {
        bail!(
            "advice artifact does not match schema v{ADVICE_SCHEMA_VERSION} at {}: {}",
            error.instance_path(),
            error
        );
    }
    if artifact.get("schema_version").and_then(Value::as_u64) != Some(ADVICE_SCHEMA_VERSION)
        || artifact
            .pointer("/validation/status")
            .and_then(Value::as_str)
            != Some("valid")
    {
        bail!("advice artifact is not a validated schema-{ADVICE_SCHEMA_VERSION} artifact");
    }
    let current = sha256(serde_json::to_vec(report)?);
    let recorded = artifact
        .pointer("/report/canonical_sha256")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if current != recorded {
        bail!(
            "stale advice artifact: recorded report digest {recorded}, current selected report digest {current}"
        );
    }
    Ok(artifact)
}