truth-mirror 0.1.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Deterministic pre-push and repository safety gates.

use std::{
    collections::BTreeSet,
    fs,
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

use anyhow::{Context, Result, bail};

use crate::{
    claim, cli,
    ledger::{LedgerEntry, LedgerStore},
};

pub fn run(args: cli::GateArgs, state_dir: &Path) -> Result<ExitCode> {
    if let Some(range) = args.pre_push {
        let commits = commits_for_range(&range)?;
        match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
            PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
            PushGateDecision::Block(entries) => {
                bail!(
                    "pre-push blocked unresolved rejection(s): {}",
                    rejection_summary(&entries)
                );
            }
        }
    }

    let commit_msg_path = args
        .commit_msg
        .as_ref()
        .context("gate requires --commit-msg or --pre-push")?;
    let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
        format!(
            "failed to read commit message {}",
            commit_msg_path.display()
        )
    })?;

    let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
    let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;

    claim::evaluate_commit_message(
        &commit_message,
        claim_file.as_deref(),
        diff_file.as_deref(),
        &args.fake_markers,
    )?;

    Ok(ExitCode::SUCCESS)
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushGateDecision {
    Allow,
    Block(Vec<LedgerEntry>),
}

pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
    let blocked = store
        .unresolved_rejections()?
        .into_iter()
        .filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
        .collect::<Vec<_>>();

    if blocked.is_empty() {
        Ok(PushGateDecision::Allow)
    } else {
        Ok(PushGateDecision::Block(blocked))
    }
}

fn commits_for_range(range: &str) -> Result<Vec<String>> {
    if range == "all" {
        return Ok(Vec::new());
    }

    if !range.contains("..") {
        return Ok(vec![range.to_owned()]);
    }

    let output = Command::new("git")
        .args(["rev-list", range])
        .output()
        .context("failed to run git rev-list for pre-push range")?;
    if !output.status.success() {
        bail!(
            "git rev-list failed for pre-push range {range}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(str::to_owned)
        .collect())
}

fn rejection_summary(entries: &[LedgerEntry]) -> String {
    entries
        .iter()
        .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
        .collect::<Vec<_>>()
        .join("; ")
}

fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
    path.map(|path| {
        fs::read_to_string(path)
            .with_context(|| format!("failed to read {label} {}", path.display()))
    })
    .transpose()
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};

    use super::{PushGateDecision, pre_push_decision};

    fn reject_entry(sha: &str) -> LedgerEntry {
        LedgerEntry::new_at(
            sha,
            Verdict::Reject,
            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
            vec!["tests:cargo-test".to_owned()],
            ReviewerConfig::new("claude", "opus", false),
            vec!["unsupported".to_owned()],
            100,
        )
    }

    #[test]
    fn pre_push_blocks_unresolved_rejection_in_range() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store.append_entry(&reject_entry("abc123")).unwrap();

        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();

        assert!(matches!(decision, PushGateDecision::Block(_)));
    }

    #[test]
    fn pre_push_allows_after_resolve_or_waive() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store.append_entry(&reject_entry("abc123")).unwrap();
        store.resolve("abc123").unwrap();

        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();

        assert_eq!(decision, PushGateDecision::Allow);
    }

    proptest! {
        #[test]
        fn pushed_range_decision_blocks_only_matching_unresolved_sha(
            rejected_sha in "[a-f0-9]{7,40}",
            other_sha in "[a-f0-9]{7,40}",
        ) {
            prop_assume!(rejected_sha != other_sha);
            let temp = tempfile::tempdir().unwrap();
            let store = LedgerStore::new(temp.path());
            store.append_entry(&reject_entry(&rejected_sha)).unwrap();

            let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
            prop_assert_eq!(unrelated, PushGateDecision::Allow);

            let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
            prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
        }
    }
}