fairlead 0.15.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
//! `fairlead guard check`: the check stage over tracked files with the
//! ratchet, or with `--staged` the commit stage over what the next commit adds.

use std::collections::BTreeSet;
use std::path::Path;
use std::process::ExitCode;
use std::time::Instant;

use clap::Subcommand;
use fairlead_core::config::{self, LoadOptions};
use fairlead_guard::baseline::{self, Counts};
use fairlead_guard::events::{Event, EventLog};
use fairlead_guard::{added, git, stages, Finding, Guard, Source};
use rayon::prelude::*;

#[derive(Subcommand)]
pub enum GuardAction {
    /// Check tracked files against every rule, and ratcheted rules against the baseline.
    Check {
        /// Check only what the next commit adds: each staged file before and after.
        #[arg(long)]
        staged: bool,
        /// Print every finding, including those the baseline holds.
        #[arg(long)]
        list: bool,
        /// Record the ratcheted counts as they are now.
        #[arg(long, conflicts_with = "staged")]
        write_baseline: bool,
        /// Check migrations against the merge base with this revision,
        /// over `guard.migrations.base`.
        #[arg(long, value_name = "REV", conflicts_with = "staged")]
        base: Option<String>,
    },
    /// The write stage: read a Claude Code PreToolUse call on stdin and deny
    /// it, or add a note, when it breaks a rule. `fairlead hooks install` runs it.
    Hook,
    /// The Claude Code Stop hook: send the agent back while the tree it
    /// leaves hasn't passed `fairlead done`. `fairlead hooks install` runs it.
    Stop,
    /// The Claude Code PostToolUse hook: once per session, after an edit made
    /// with no brief, a note saying how to get one. `fairlead hooks install` runs it.
    Nudge,
    /// Compare another linter's findings, one `file:line rule` per line, with
    /// the check stage's, and fail on any difference.
    Compare {
        /// The other linter's output, or `-` for stdin.
        file: String,
        /// Only these rules, on both sides.
        #[arg(long, value_delimiter = ',')]
        rules: Vec<String>,
        /// Read the other linter's rule names as Fairlead's: `theirs=ours`.
        #[arg(long = "map", value_name = "THEIRS=OURS")]
        maps: Vec<String>,
    },
    /// Replay the files each commit since REV changed through the write hook,
    /// and report how long it took and what it decided.
    Bench {
        /// Replay the commits after this one, on HEAD's first-parent line.
        #[arg(long, value_name = "REV")]
        since: String,
        /// Stop after this many edits.
        #[arg(long, default_value_t = 200)]
        limit: usize,
        /// Fail when the 95th percentile is over this many milliseconds.
        #[arg(long, value_name = "MS")]
        p95_under: Option<f64>,
    },
}

fn fail(message: impl std::fmt::Display) -> ExitCode {
    eprintln!("guard: {message}");
    ExitCode::FAILURE
}

pub fn run(action: GuardAction, sets: Vec<String>, cwd: &Path) -> ExitCode {
    if matches!(action, GuardAction::Hook) {
        return crate::hook_cmd::run();
    }
    if matches!(action, GuardAction::Stop) {
        return crate::done_cmd::stop();
    }
    if matches!(action, GuardAction::Nudge) {
        return crate::brief_cmd::nudge();
    }
    if let GuardAction::Bench {
        since,
        limit,
        p95_under,
    } = &action
    {
        return crate::bench_cmd::run(cwd, since, *limit, *p95_under);
    }
    let loaded = match config::load(cwd, &LoadOptions::from_process(sets)) {
        Ok(loaded) => loaded,
        Err(e) => return fail(e),
    };
    if !loaded.problems.is_empty() {
        for p in &loaded.problems {
            eprintln!("{}: {}", p.key, p.message);
        }
        return fail("the config has problems; run `fairlead config check`");
    }
    let root = if loaded.files.is_empty() {
        crate::graph_cmd::repo_root(cwd)
    } else {
        loaded.root.clone()
    };
    let guard = match Guard::new(&loaded.config.guard, &root) {
        Ok(guard) => guard,
        Err(e) => return fail(e),
    };
    if guard.is_empty() {
        println!("guard: no rules configured");
        return ExitCode::SUCCESS;
    }
    if let GuardAction::Compare { file, rules, maps } = &action {
        return crate::bench_cmd::compare(&root, &guard, file, rules, maps);
    }
    let GuardAction::Check {
        staged,
        list,
        write_baseline,
        base,
    } = action
    else {
        unreachable!("the other actions returned above");
    };
    if staged {
        return check_staged(&root, &guard, &loaded.config.guard, list);
    }
    let baseline_path = root.join(&loaded.config.guard.baseline);
    let tree = Tree {
        list,
        write: write_baseline,
        base: base.as_deref(),
    };
    check_tree(&root, &guard, &baseline_path, tree)
}

/// How the check stage was asked to run.
struct Tree<'a> {
    list: bool,
    write: bool,
    base: Option<&'a str>,
}

/// Every finding the check stage sees over the tracked files, sorted.
pub fn tree_findings(
    root: &Path,
    guard: &Guard,
    base: Option<&str>,
) -> Result<Vec<Finding>, String> {
    let files = git::tracked(root)?;
    // In parallel, and still in path order, since collect keeps it.
    let mut findings: Vec<Finding> = files
        .par_iter()
        .filter(|p| guard.reads(p))
        .map(|path| match std::fs::read_to_string(root.join(path)) {
            Ok(text) => guard.lint(&Source::new(path, &text)),
            // Deleted but not yet staged, or not text: nothing to read.
            Err(_) => Vec::new(),
        })
        .collect::<Vec<_>>()
        .into_iter()
        .flatten()
        .collect();
    let more = stages::tree(guard, root, &files, base)?;
    findings.extend(more.findings);
    for note in more.notes {
        eprintln!("guard: {note}");
    }
    fairlead_guard::sort(&mut findings);
    Ok(findings)
}

fn check_tree(root: &Path, guard: &Guard, baseline_path: &Path, how: Tree<'_>) -> ExitCode {
    let Tree { list, write, base } = how;
    let findings = match tree_findings(root, guard, base) {
        Ok(findings) => findings,
        Err(e) => return fail(e),
    };
    if list {
        for f in &findings {
            println!("{f}");
        }
    }
    let ratcheted = guard.ratcheted();
    let (held, zero): (Vec<&Finding>, Vec<&Finding>) =
        findings.iter().partition(|f| ratcheted.contains(f.rule));
    let now = baseline::tally(held.iter().copied());
    if write {
        return write_baseline(baseline_path, &now, held.len());
    }
    let was = match baseline::read(baseline_path) {
        Ok(counts) => only_rules(counts, &ratcheted),
        Err(e) => return fail(e),
    };
    let changes = baseline::changes(&now, &was);
    let (worse, fell): (Vec<_>, Vec<_>) = changes.into_iter().partition(|c| c.now > c.was);
    if !zero.is_empty() || !worse.is_empty() {
        eprintln!(
            "guard: {} finding(s) with no baseline, {} ratcheted file/rule pair(s) above the baseline",
            zero.len(),
            worse.len()
        );
        if !list {
            let over: BTreeSet<(&str, &str)> = worse
                .iter()
                .map(|w| (w.file.as_str(), w.rule.as_str()))
                .collect();
            let shown = held
                .iter()
                .filter(|f| over.contains(&(f.file.as_str(), f.rule)));
            for f in zero.iter().chain(shown) {
                eprintln!("{f}");
            }
        }
        for w in &worse {
            eprintln!(
                "  {} {}: {} allowed, {} found",
                w.file, w.rule, w.was, w.now
            );
        }
        eprintln!("Fix them, or record a ratcheted count on purpose with `fairlead guard check --write-baseline`.");
        return ExitCode::FAILURE;
    }
    println!(
        "guard: clean, {} finding(s) held at the baseline",
        held.len()
    );
    let mut by_rule = std::collections::BTreeMap::<&str, usize>::new();
    for f in &held {
        *by_rule.entry(f.rule).or_default() += 1;
    }
    for (rule, n) in by_rule {
        println!("  {rule}: {n}");
    }
    if !fell.is_empty() {
        println!(
            "guard: {} ratcheted count(s) fell; `fairlead guard check --write-baseline` lowers the baseline to match",
            fell.len()
        );
    }
    ExitCode::SUCCESS
}

/// A rule no longer ratcheted, or no longer configured, has nothing to hold.
fn only_rules(counts: Counts, rules: &BTreeSet<&'static str>) -> Counts {
    counts
        .into_iter()
        .map(|(file, by_rule)| {
            let kept = by_rule
                .into_iter()
                .filter(|(r, _)| rules.contains(r.as_str()))
                .collect();
            (file, kept)
        })
        .filter(
            |(_, by_rule): &(String, std::collections::BTreeMap<String, u64>)| !by_rule.is_empty(),
        )
        .collect()
}

fn write_baseline(path: &Path, counts: &Counts, total: usize) -> ExitCode {
    if let Err(e) = baseline::write(path, counts) {
        return fail(format!("{}: {e}", path.display()));
    }
    println!(
        "guard: baseline written to {}, {total} finding(s) across {} file(s)",
        path.display(),
        counts.len()
    );
    ExitCode::SUCCESS
}

fn check_staged(root: &Path, guard: &Guard, settings: &config::Guard, list: bool) -> ExitCode {
    let start = Instant::now();
    let staged = match git::staged(root) {
        Ok(staged) => staged,
        Err(e) => return fail(e),
    };
    let mut new = Vec::new();
    let mut read = 0;
    for file in staged.iter().filter(|s| guard.reads(&s.path)) {
        let Some(after) = git::in_index(root, &file.path) else {
            continue;
        };
        read += 1;
        // Both sides are linted under the new path, so a rename compares like an edit.
        let lint = |text: &str| guard.lint(&Source::new(&file.path, text));
        let now = lint(&after);
        if settings.findings == config::Findings::All {
            new.extend(now);
            continue;
        }
        let before = file
            .before
            .as_deref()
            .and_then(|b| git::at_head(root, b))
            .map(|text| lint(&text))
            .unwrap_or_default();
        new.extend(added::added(&before, &now));
    }
    let paths: Vec<String> = staged.iter().map(|s| s.path.clone()).collect();
    match stages::staged(guard, root, &paths) {
        Ok(more) => new.extend(more),
        Err(e) => return fail(e),
    }
    fairlead_guard::sort(&mut new);
    let warn = settings.on_finding == config::OnFinding::Warn;
    let decision = match (new.is_empty(), warn) {
        (true, _) => "allow",
        (false, true) => "warn",
        (false, false) => "deny",
    };
    let mut event = Event::new("commit", decision, start.elapsed());
    event.files = Some(read);
    event.added = new.len();
    event.rules = new
        .iter()
        .map(|f| f.rule)
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();
    let log = match settings.events {
        config::Events::Local => EventLog::open(root),
        config::Events::Off => None,
    };
    if let Some(log) = log {
        // The log is a record, never a reason to block or fail a commit.
        let _ = log.append(&event);
    }
    if list {
        for f in &new {
            println!("{f}");
        }
    }
    let what = match settings.findings {
        config::Findings::Added => "the staged changes add",
        config::Findings::All => "the staged files have",
    };
    if new.is_empty() {
        println!("guard: {read} staged file(s), {what} no findings");
        return ExitCode::SUCCESS;
    }
    let then = if warn { ", committing anyway" } else { "" };
    eprintln!("guard: {what} {} finding(s){then}:", new.len());
    if !list {
        for f in &new {
            eprintln!("{f}");
        }
    }
    if warn {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    }
}