repotoire 0.8.2

Graph-powered code analysis CLI. 110 detectors for security, architecture, bus factor, and code quality.
Documentation
pub mod fingerprint;

use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

pub const BASELINE_FILENAME: &str = "repotoire-baseline.json";
const BASELINE_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
    pub version: u32,
    pub accepted_at: String,
    pub findings: Vec<BaselineEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineEntry {
    pub detector: String,
    pub fingerprint: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qualified_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub first_line_content: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accepted_by: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

impl Baseline {
    pub fn empty() -> Self {
        Baseline {
            version: BASELINE_VERSION,
            accepted_at: chrono::Utc::now().to_rfc3339(),
            findings: Vec::new(),
        }
    }

    pub fn load(repo_root: &Path) -> anyhow::Result<Self> {
        let path = repo_root.join(BASELINE_FILENAME);
        if !path.exists() {
            return Ok(Self::empty());
        }
        let content = std::fs::read_to_string(&path)?;
        let baseline: Baseline = serde_json::from_str(&content)?;
        Ok(baseline)
    }

    /// Load the baseline at `repo_root` for use by the engine's
    /// filter stage. Distinguishes the legitimate "no baseline yet"
    /// case (file does not exist) from "baseline exists but failed
    /// to parse" (merge conflict markers, half-written file, schema
    /// mismatch).
    ///
    /// Behavior:
    /// - File does not exist: `Ok(None)`. Engine proceeds with no
    ///   suppression (legitimate first-run case).
    /// - File exists and parses cleanly: `Ok(Some(baseline))`.
    /// - File exists but fails to parse: `Err` with an actionable
    ///   message naming the path and giving the user two remediation
    ///   paths (delete to start fresh, restore from VCS).
    ///
    /// The previous `Baseline::load(...).ok()` pattern at the engine
    /// call sites collapsed cases 1 and 3 into a silent "no
    /// suppression" — turning a corrupt baseline (typically a
    /// VCS-checked-in file with merge conflict markers from a bad
    /// merge) into hundreds of "new" findings on a CI run with no
    /// signal that anything was wrong. See
    /// docs/superpowers/specs/2026-05-11-cache-validation.md.
    pub fn load_for_filter(repo_root: &Path) -> anyhow::Result<Option<Self>> {
        let path = repo_root.join(BASELINE_FILENAME);
        if !path.exists() {
            return Ok(None);
        }
        let content = std::fs::read_to_string(&path).map_err(|e| {
            anyhow::anyhow!(
                "Failed to read baseline at {}: {e}. \
                 Delete the file to start fresh, or restore it from version control.",
                path.display()
            )
        })?;
        let baseline: Baseline = serde_json::from_str(&content).map_err(|e| {
            anyhow::anyhow!(
                "Baseline at {} is corrupt and cannot be parsed: {e}. \
                 This commonly happens when a merge conflict was committed without \
                 resolving the baseline file. Delete the file to start fresh, or \
                 restore it from version control.",
                path.display()
            )
        })?;
        Ok(Some(baseline))
    }

    pub fn save(&self, repo_root: &Path) -> anyhow::Result<PathBuf> {
        let path = repo_root.join(BASELINE_FILENAME);
        let content = serde_json::to_string_pretty(self)?;
        std::fs::write(&path, &content)?;
        Ok(path)
    }

    pub fn contains(&self, fingerprint: &str) -> bool {
        self.findings.iter().any(|e| e.fingerprint == fingerprint)
    }

    pub fn get(&self, fingerprint: &str) -> Option<&BaselineEntry> {
        self.findings.iter().find(|e| e.fingerprint == fingerprint)
    }

    pub fn add(&mut self, entry: BaselineEntry) -> bool {
        if self.contains(&entry.fingerprint) {
            return false;
        }
        self.findings.push(entry);
        true
    }

    pub fn prune(&mut self, active_fingerprints: &HashSet<String>) -> usize {
        let before = self.findings.len();
        self.findings
            .retain(|e| active_fingerprints.contains(&e.fingerprint));
        before - self.findings.len()
    }

    pub fn fingerprints(&self) -> HashSet<String> {
        self.findings
            .iter()
            .map(|e| e.fingerprint.clone())
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_load_nonexistent_returns_empty() {
        let dir = tempdir().unwrap();
        let baseline = Baseline::load(dir.path()).unwrap();
        assert!(baseline.findings.is_empty());
        assert_eq!(baseline.version, 1);
    }

    #[test]
    fn test_save_and_load_roundtrip() {
        let dir = tempdir().unwrap();
        let mut baseline = Baseline::empty();
        baseline.add(BaselineEntry {
            detector: "god-class".into(),
            fingerprint: "abc123".into(),
            qualified_name: Some("mod::MyClass".into()),
            file: None,
            first_line_content: None,
            accepted_by: Some("zach".into()),
            reason: Some("facade pattern".into()),
        });
        baseline.save(dir.path()).unwrap();

        let loaded = Baseline::load(dir.path()).unwrap();
        assert_eq!(loaded.findings.len(), 1);
        assert_eq!(loaded.findings[0].detector, "god-class");
        assert_eq!(loaded.findings[0].fingerprint, "abc123");
    }

    #[test]
    fn test_add_deduplicates_by_fingerprint() {
        let mut baseline = Baseline::empty();
        let entry = BaselineEntry {
            detector: "god-class".into(),
            fingerprint: "abc123".into(),
            qualified_name: None,
            file: None,
            first_line_content: None,
            accepted_by: None,
            reason: None,
        };
        assert!(baseline.add(entry.clone()));
        assert!(!baseline.add(entry));
        assert_eq!(baseline.findings.len(), 1);
    }

    #[test]
    fn test_prune_removes_stale_entries() {
        let mut baseline = Baseline::empty();
        baseline.add(BaselineEntry {
            detector: "a".into(),
            fingerprint: "keep".into(),
            qualified_name: None,
            file: None,
            first_line_content: None,
            accepted_by: None,
            reason: None,
        });
        baseline.add(BaselineEntry {
            detector: "b".into(),
            fingerprint: "remove".into(),
            qualified_name: None,
            file: None,
            first_line_content: None,
            accepted_by: None,
            reason: None,
        });
        let active: HashSet<String> = ["keep".into()].into();
        let removed = baseline.prune(&active);
        assert_eq!(removed, 1);
        assert_eq!(baseline.findings.len(), 1);
        assert_eq!(baseline.findings[0].fingerprint, "keep");
    }

    // ── load_for_filter: the engine's load surface ──

    #[test]
    fn load_for_filter_returns_none_when_file_missing() {
        let dir = tempdir().unwrap();
        // No baseline file present — this is the legitimate
        // "no suppression yet" first-run case.
        let loaded = Baseline::load_for_filter(dir.path()).expect("ok for missing file");
        assert!(loaded.is_none());
    }

    #[test]
    fn load_for_filter_returns_baseline_when_file_valid() {
        let dir = tempdir().unwrap();
        let mut baseline = Baseline::empty();
        baseline.add(BaselineEntry {
            detector: "d".into(),
            fingerprint: "fp".into(),
            qualified_name: None,
            file: None,
            first_line_content: None,
            accepted_by: None,
            reason: None,
        });
        baseline.save(dir.path()).unwrap();

        let loaded = Baseline::load_for_filter(dir.path())
            .expect("ok for valid file")
            .expect("Some(_) when file exists and parses");
        assert_eq!(loaded.findings.len(), 1);
    }

    #[test]
    fn load_for_filter_errors_on_corrupt_baseline() {
        // Bug 2 regression guard: a corrupt baseline (merge conflict
        // markers, half-written file) must NOT silently degrade to
        // "no suppression" — that turns hundreds of previously
        // suppressed findings into "new" findings with no warning.
        let dir = tempdir().unwrap();
        let path = dir.path().join(BASELINE_FILENAME);
        std::fs::write(
            &path,
            "<<<<<<< HEAD\n{\"version\": 1}\n=======\n{}\n>>>>>>> other\n",
        )
        .unwrap();
        let err = Baseline::load_for_filter(dir.path())
            .expect_err("corrupt baseline must produce an error, not Ok(None)");
        let msg = format!("{err:#}");
        assert!(
            msg.contains(BASELINE_FILENAME),
            "error must reference the baseline path; got: {msg}",
        );
        assert!(
            msg.contains("Delete") || msg.contains("delete"),
            "error must give a remediation; got: {msg}",
        );
    }
}