truth-mirror 0.9.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! `truth-mirror debt` subcommand: surfaces FLAG-class findings for the human.
//!
//! FLAGs accumulate silently — they do not block push, gate, or reinjection.
//! The debt view groups them by finding class (severity + title); an entry
//! with findings in several classes appears under each of them. Classes print
//! in alphabetical order; within a class, entries print newest first so the
//! human can decide whether to ignore, escalate, or seed a memory-skill
//! candidate from the pattern.

use std::{collections::BTreeMap, path::Path, process::ExitCode};

use anyhow::Result;

use crate::{cli::DebtArgs, ledger::LedgerStore};

pub fn run(_args: DebtArgs, state_dir: &Path) -> Result<ExitCode> {
    let store = LedgerStore::new(state_dir);
    let entries = store.flagged_entries()?;
    if entries.is_empty() {
        println!("No FLAG-class debt.");
        return Ok(ExitCode::SUCCESS);
    }

    let mut sorted = entries;
    sorted.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_unix));

    let by_class = group_by_finding_class(&sorted);

    println!(
        "truth-mirror debt (FLAG-class findings; classes alphabetical, entries newest first):\n"
    );
    for (class, group) in &by_class {
        println!("== {} ({} entries) ==", class, group.len());
        for entry in group {
            println!(
                "  commit: {} ({} ago) reviewer={}/{}",
                entry.commit_sha,
                age_label(crate::time::unix_now().saturating_sub(entry.updated_at_unix)),
                entry.reviewer.harness,
                entry.reviewer.model
            );
            if !entry.summary.trim().is_empty() {
                println!("  summary: {}", entry.summary);
            }
            for finding in &entry.structured_findings {
                println!("    - {}", finding.display_line());
            }
            if entry.structured_findings.is_empty() {
                for finding in &entry.findings {
                    println!("    - {finding}");
                }
            }
        }
        println!();
    }

    Ok(ExitCode::SUCCESS)
}

/// Group by EVERY structured finding's class (`severity:title`), not just the
/// first — an entry whose findings span classes must surface under each of
/// them, or the "group by finding class" promise silently drops debt. Entries
/// with no structured findings land under "unclassified". BTreeMap keys keep
/// classes alphabetical; the caller pre-sorts entries newest-first.
fn group_by_finding_class(
    sorted: &[crate::ledger::LedgerEntry],
) -> BTreeMap<String, Vec<&crate::ledger::LedgerEntry>> {
    let mut by_class: BTreeMap<String, Vec<&crate::ledger::LedgerEntry>> = BTreeMap::new();
    for entry in sorted {
        // BTreeSet, not Vec::dedup — dedup() only removes ADJACENT repeats,
        // so findings ordered A, B, A would list the entry twice under A.
        let mut classes: std::collections::BTreeSet<String> = entry
            .structured_findings
            .iter()
            .map(|finding| format!("{}:{}", finding.severity, finding.title))
            .collect();
        if classes.is_empty() {
            classes.insert("unclassified".to_owned());
        }
        for class in classes {
            by_class.entry(class).or_default().push(entry);
        }
    }
    by_class
}

fn age_label(secs: u64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 60 * 60 {
        format!("{}m", secs / 60)
    } else if secs < 60 * 60 * 24 {
        format!("{}h", secs / 3600)
    } else {
        format!("{}d", secs / 86400)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ledger::{
        FindingSeverity, LedgerEntry, LedgerStore, ReviewerConfig, StructuredFinding, Verdict,
    };

    fn reviewer() -> ReviewerConfig {
        ReviewerConfig::new("claude", "claude-opus-4-1", false)
    }

    fn flagged(
        sha: &str,
        severity: FindingSeverity,
        title: &str,
        summary: &str,
        timestamp: u64,
    ) -> LedgerEntry {
        LedgerEntry::new_at(
            sha,
            Verdict::Flag,
            "CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
            vec!["tests:cargo-test".to_owned()],
            reviewer(),
            vec![title.to_owned()],
            timestamp,
        )
        .with_structured_review(
            summary,
            vec![StructuredFinding {
                severity,
                title: title.to_owned(),
                body: summary.to_owned(),
                file: "src/lib.rs".to_owned(),
                line_start: 1,
                line_end: 1,
                confidence: 70,
                recommendation: "Tighten evidence.".to_owned(),
            }],
            vec![],
            r#"{"verdict":"FLAG"}"#,
        )
    }

    #[test]
    fn debt_is_quiet_with_no_flags() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store
            .append_entry(&LedgerEntry::new_at(
                "pass1",
                Verdict::Pass,
                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
                vec!["tests:cargo-test".to_owned()],
                reviewer(),
                Vec::new(),
                100,
            ))
            .unwrap();

        // Sanity: the flagged_entries() helper sees nothing.
        assert!(store.flagged_entries().unwrap().is_empty());
        let _ = temp;
    }

    #[test]
    fn flagged_entries_returns_only_flag_records() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store
            .append_entry(&flagged(
                "flag1",
                FindingSeverity::Medium,
                "thin evidence",
                "Claim holds but evidence is thin.",
                100,
            ))
            .unwrap();
        store
            .append_entry(&LedgerEntry::new_at(
                "rej1",
                Verdict::Reject,
                "CLAIM: rejected | verified: cargo test | evidence: tests:cargo-test",
                vec!["tests:cargo-test".to_owned()],
                reviewer(),
                vec!["claim unsupported".to_owned()],
                110,
            ))
            .unwrap();

        let flagged = store.flagged_entries().unwrap();
        assert_eq!(flagged.len(), 1);
        assert_eq!(flagged[0].commit_sha, "flag1");
        assert_eq!(flagged[0].verdict, Verdict::Flag);
    }

    #[test]
    fn age_label_renders_minutes_hours_days() {
        assert_eq!(age_label(0), "0s");
        assert_eq!(age_label(45), "45s");
        assert_eq!(age_label(60), "1m");
        assert_eq!(age_label(60 * 30), "30m");
        assert_eq!(age_label(60 * 60 * 5), "5h");
        assert_eq!(age_label(60 * 60 * 24 * 3), "3d");
    }

    #[test]
    fn grouping_dedupes_non_adjacent_repeated_classes() {
        use super::group_by_finding_class;
        use crate::ledger::FindingSeverity;

        // Findings ordered A, B, A — Vec::dedup only removed ADJACENT
        // repeats, listing the entry twice under class A.
        let mut entry = flagged(
            "flag1",
            FindingSeverity::Medium,
            "thin evidence",
            "Claim holds but evidence is thin.",
            100,
        );
        let mut findings = entry.structured_findings.clone();
        let mut other = findings[0].clone();
        other.title = "different class".to_owned();
        findings.push(other);
        findings.push(findings[0].clone());
        entry.structured_findings = findings;

        let entries = vec![entry];
        let grouped = group_by_finding_class(&entries);
        assert_eq!(grouped.len(), 2);
        for (_, group) in grouped {
            assert_eq!(group.len(), 1);
        }
    }
}