git-slop 0.11.0

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
use super::*;
use std::io::Read;

fn collect_prompt_paths(value: &Value, paths: &mut BTreeSet<String>) {
    match value {
        Value::Object(object) => {
            for (key, value) in object {
                if matches!(key.as_str(), "path" | "source_path" | "target_path") {
                    if let Some(path) = value.as_str() {
                        paths.insert(path.to_string());
                    }
                } else if matches!(key.as_str(), "scope_paths" | "in_scope") {
                    if let Some(values) = value.as_array() {
                        paths.extend(
                            values
                                .iter()
                                .filter_map(Value::as_str)
                                .map(ToOwned::to_owned),
                        );
                    }
                }
                collect_prompt_paths(value, paths);
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_prompt_paths(value, paths);
            }
        }
        _ => {}
    }
}

fn bounded_repository_text(root: &Path, relative: &str, byte_limit: usize) -> Option<Value> {
    let relative_path = Path::new(relative);
    if relative_path.as_os_str().is_empty()
        || relative_path.is_absolute()
        || relative_path.components().any(|component| {
            matches!(
                component,
                std::path::Component::ParentDir | std::path::Component::RootDir
            )
        })
    {
        return None;
    }
    let absolute = root.join(relative_path);
    let metadata = fs::symlink_metadata(&absolute).ok()?;
    if !metadata.is_file() || metadata.file_type().is_symlink() {
        return None;
    }
    let mut bytes = Vec::with_capacity(byte_limit.saturating_add(1));
    std::io::Read::read_to_end(
        &mut fs::File::open(absolute)
            .ok()?
            .take(u64::try_from(byte_limit.saturating_add(1)).ok()?),
        &mut bytes,
    )
    .ok()?;
    let truncated = bytes.len() > byte_limit;
    bytes.truncate(byte_limit);
    Some(json!({
        "path": relative,
        "excerpt": String::from_utf8_lossy(&bytes),
        "bytes_returned": bytes.len(),
        "truncated": truncated
    }))
}

fn repository_context(
    payload: &Value,
    _report: &Value,
    root: &Path,
    excerpt_bytes: usize,
) -> Value {
    let byte_limit = excerpt_bytes.clamp(256, 4096);
    let mut candidate_paths = BTreeSet::new();
    collect_prompt_paths(payload, &mut candidate_paths);
    let source_excerpts = candidate_paths
        .iter()
        .filter_map(|path| bounded_repository_text(root, path, byte_limit))
        .take(10)
        .collect::<Vec<_>>();
    let guidance = ["AGENTS.md", "CONTRIBUTING.md", "README.md"]
        .iter()
        .filter_map(|path| bounded_repository_text(root, path, byte_limit))
        .collect::<Vec<_>>();
    let verification_commands = if root.join("Cargo.toml").is_file() {
        vec![
            "cargo fmt --all -- --check",
            "cargo clippy --all-targets --all-features -- -D warnings",
            "cargo test --all-targets",
        ]
    } else if root.join("go.mod").is_file() {
        vec!["go test ./..."]
    } else if root.join("pyproject.toml").is_file() {
        vec!["pytest"]
    } else if root.join("package.json").is_file() {
        vec!["npm test"]
    } else {
        Vec::new()
    };
    json!({
        "included": true,
        "reason": "explicit_opt_in",
        "source_excerpts": source_excerpts,
        "guidance": guidance,
        "verification_commands": verification_commands,
        "truncation": {
            "per_file_byte_limit": byte_limit,
            "source_file_limit": 10,
            "source_candidate_count": candidate_paths.len(),
            "source_returned_count": source_excerpts.len(),
            "guidance_file_limit": 3,
            "guidance_returned_count": guidance.len()
        }
    })
}

pub fn health_json_payload(report: &Value) -> Value {
    json!({
        "schema_version": 1,
        "command": "health",
        "report": {
            "schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
            "analyzer": report.get("analyzer").cloned().unwrap_or(Value::Null),
            "repo": report.get("repo").cloned().unwrap_or(Value::Null),
            "scope": report.get("scope").cloned().unwrap_or(Value::Null),
        },
        "health": report.get("health").cloned().unwrap_or_else(|| json!({"findings": []})),
        "collection_metadata": report.pointer("/collection_metadata/health.findings").cloned().unwrap_or_else(|| json!({
            "total": report.pointer("/health/findings").and_then(Value::as_array).map(Vec::len).unwrap_or_default(),
            "returned": report.pointer("/health/findings").and_then(Value::as_array).map(Vec::len).unwrap_or_default(),
            "limit": null,
            "truncated": false
        }))
    })
}

fn github_property_escape(value: &str) -> String {
    value
        .replace('%', "%25")
        .replace('\r', "%0D")
        .replace('\n', "%0A")
        .replace(':', "%3A")
        .replace(',', "%2C")
}

fn github_message_escape(value: &str) -> String {
    value
        .replace('%', "%25")
        .replace('\r', "%0D")
        .replace('\n', "%0A")
}

fn github_annotation_level(severity: &str) -> &'static str {
    if severity.eq_ignore_ascii_case("notice") {
        "notice"
    } else if severity.eq_ignore_ascii_case("error") {
        "error"
    } else {
        "warning"
    }
}

pub fn render_github_annotations(report: &Value, max_annotations: usize) -> String {
    let lines: Vec<String> = array_at(report, &["health", "findings"])
        .iter()
        .take(max_annotations)
        .map(|finding| {
            let severity = string(finding.get("severity"));
            let command = github_annotation_level(&severity);
            let path = github_property_escape(&string(finding.get("path")));
            let title = github_property_escape(&string(finding.get("title")));
            let mut message = string(finding.get("message"));
            let next_command = string(finding.get("next_command"));
            if !next_command.is_empty() {
                if !message.is_empty() && !message.chars().last().is_some_and(char::is_whitespace) {
                    message.push(' ');
                }
                message.push_str("Next: ");
                message.push_str(&next_command);
            }
            format!(
                "::{command} file={path},title={title}::{}",
                github_message_escape(&message)
            )
        })
        .collect();
    if lines.is_empty() {
        String::new()
    } else {
        format!("{}\n", lines.join("\n"))
    }
}

pub fn write_prompt_pack(
    command: &str,
    payload: &Value,
    report: &Value,
    output_dir: &Path,
    repository_root: Option<&Path>,
    excerpt_bytes: usize,
) -> Result<()> {
    if output_dir.exists() {
        if !output_dir.is_dir() {
            bail!(
                "Prompt pack path is not a directory: {}",
                output_dir.display()
            );
        }
        bail!(
            "Prompt pack path already exists; choose a new directory: {}",
            output_dir.display()
        );
    }
    let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent)?;
    let name = output_dir
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("prompt-pack");
    let temporary = parent.join(format!(".{name}-{}-tmp", std::process::id()));
    if temporary.exists() {
        fs::remove_dir_all(&temporary)?;
    }
    fs::create_dir(&temporary)?;
    let report_digest = serde_json::to_vec(report)
        .map(|bytes| hex::encode(Sha256::digest(bytes)))
        .unwrap_or_default();
    let evidence_excerpts = array_at(report, &["files"])
        .iter()
        .take(20)
        .map(|file| {
            json!({
                "path": file.get("path"),
                "slop_score": file.get("slop_score"),
                "slop_band": file.get("slop_band"),
                "context_band": file.get("context_band"),
                "reason_codes": file.get("reason_codes"),
                "content_fingerprint": file.get("content_fingerprint")
            })
        })
        .collect::<Vec<_>>();
    let repository_context = repository_root.map_or_else(
        || {
            json!({
                "included": false,
                "reason": "not_requested",
                "source_excerpts": [],
                "guidance": [],
                "verification_commands": [],
                "truncation": {"per_file_byte_limit": 0, "file_limit": 0}
            })
        },
        |root| repository_context(payload, report, root, excerpt_bytes),
    );
    let context = json!({
        "prompt_pack_version": 1,
        "command": command,
        "payload": payload,
        "provenance": {
            "analyzer_version": report.pointer("/analyzer/version").cloned().unwrap_or(Value::Null),
            "analysis_contract_version": report.pointer("/analyzer/analysis_contract_version").cloned().unwrap_or(Value::Null),
            "analysis_config_digest": report.pointer("/analyzer/analysis_config_digest").cloned().unwrap_or(Value::Null),
            "evidence_config_digest": report.pointer("/analyzer/evidence_config_digest").cloned().unwrap_or(Value::Null),
            "policy_config_digest": report.pointer("/analyzer/policy_config_digest").cloned().unwrap_or(Value::Null),
            "presentation_config_digest": report.pointer("/analyzer/presentation_config_digest").cloned().unwrap_or(Value::Null),
            "head_sha": report.pointer("/repo/head_sha").cloned().unwrap_or(Value::Null),
            "generated_at": report.get("generated_at").cloned().unwrap_or(Value::Null),
            "analyzed_revision_at": report.get("analyzed_revision_at").cloned().unwrap_or(Value::Null),
            "worktree_state_digest": report.pointer("/repo/worktree_state_digest").cloned().unwrap_or(Value::Null),
            "analyzed_content_digest": report.pointer("/repo/analyzed_content_digest").cloned().unwrap_or(Value::Null),
            "evidence_completeness": report.get("evidence_completeness").cloned().unwrap_or(Value::Null),
            "report_sha256": report_digest.clone(),
        },
        "report_excerpt": {
            "schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
            "repo": report.get("repo").cloned().unwrap_or_else(|| json!({})),
            "summary": report.get("summary").cloned().unwrap_or_else(|| json!({})),
            "stats": report.get("stats").cloned().unwrap_or_else(|| json!({})),
            "analyzer": report.get("analyzer").cloned().unwrap_or_else(|| json!({})),
            "evidence_excerpts": evidence_excerpts,
            "collection_metadata": report.get("collection_metadata").cloned().unwrap_or(Value::Null),
            "truncation": {"evidence_excerpt_limit": 20, "evidence_excerpt_total": report.get("files").and_then(Value::as_array).map(Vec::len).unwrap_or_default()},
        },
        "repository_context": repository_context,
        "boundary": "Local model output is advisory only and must not rescore detector truth or mutate code, GitHub, or report data.",
    });
    let context_json = render_json(&context)?;
    let prompt = format!(
        "# git-slop {command} prompt pack\n\n\
             Use only the facts in context.json.\n\n\
             Summarize the selected detector evidence for a maintainer. Keep hotspot\n\
             costs separate from overlay evidence. Treat model output as advisory:\n\
             do not rescore detector truth, claim correctness, or imply a refactor is\n\
             mandatory. If the supplied facts are insufficient, say what is missing\n\
             instead of inventing context.\n\n\
             Preferred output:\n\n\
             1. Brief maintainer summary\n\
             2. Strongest evidence\n\
             3. Suggested next review step\n\
             4. Boundaries and non-claims\n"
    );
    let readme = format!(
        "# git-slop {command} Prompt Pack\n\n\
             This directory was generated by git-slop for local model use.\n\n\
             Files:\n\n\
             - `context.json`: deterministic git-slop payload plus minimal report metadata.\n\
             - `prompt.md`: prompt text for a local model.\n\
             - `README.md`: these usage notes.\n\
             - `response-template.md`: evidence-aware answer structure.\n\
             - `verification.md`: reproducibility checks.\n\n\
             Boundary rules:\n\n\
             - This pack is advisory only.\n\
             - Local model output must not mutate code, GitHub, or detector truth.\n\
             - Local model output must not rescore detector truth, including\n\
               `slop_score`, `slop_band`,\n\
               `context_band`, or `git slop check` semantics.\n\
             - Keep hotspot cost separate from overlay evidence.\n\
             - `manifest.json` binds every payload file to its SHA-256 digest.\n"
    );
    let response_template = "# Maintainer response\n\n## Summary\n\n## Evidence cited\n\n- Path, metric, and content fingerprint:\n\n## Proposed next step\n\n## Verification\n\n## Missing evidence and non-claims\n".to_string();
    let verification = format!(
        "# Verification\n\n- Validate the source report: `git-slop report validate --allow-legacy <report.json>`\n- Reproduce the selected payload with `git-slop {command} --format json` and the selector in context.json.\n- Confirm context.json and prompt.md digests against manifest.json.\n- Do not treat model prose as detector evidence.\n"
    );
    let files = [
        ("context.json", context_json),
        ("prompt.md", prompt),
        ("README.md", readme),
        ("response-template.md", response_template),
        ("verification.md", verification),
    ];
    for (name, contents) in &files {
        fs::write(temporary.join(name), contents)?;
    }
    let digests = files
        .iter()
        .map(|(name, contents)| {
            (
                name.to_string(),
                hex::encode(Sha256::digest(contents.as_bytes())),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let manifest = json!({
        "schema_version": 1,
        "prompt_pack_version": 1,
        "command": command,
        "files": digests,
        "report": {
            "schema_version": report.get("schema_version").cloned().unwrap_or(Value::Null),
            "config_digest": report.pointer("/analyzer/config_digest").cloned().unwrap_or(Value::Null),
            "scope": report.get("scope").cloned().unwrap_or(Value::Null),
            "head_sha": report.pointer("/repo/head_sha").cloned().unwrap_or(Value::Null),
            "report_sha256": report_digest,
            "generated_at": report.get("generated_at").cloned().unwrap_or(Value::Null)
        }
    });
    fs::write(temporary.join("manifest.json"), render_json(&manifest)?)?;
    fs::rename(&temporary, output_dir).inspect_err(|_| {
        let _ = fs::remove_dir_all(&temporary);
    })?;
    Ok(())
}