sniff-cli 0.1.0

An exhaustive LLM-backed slop finder for codebases
Documentation
use crate::report_types::StaticFlag;
use crate::roles::is_utility_helper_name;
use crate::types::{FileRecord, FindingTier};
use std::path::{Path, PathBuf};
use std::process::Command;

use super::similarity;

struct ChurnMetrics {
    commit_count: usize,
    revert_count: usize,
}

fn collect_churn_metrics(repo_root: &Path, file_path: &str) -> Option<ChurnMetrics> {
    let rel = repo_relative_path(repo_root, file_path);
    let rel_str = rel.to_string_lossy();
    let messages = git_output(
        repo_root,
        &["log", "--follow", "--format=%s", "--", &rel_str],
    )?
    .lines()
    .map(|line| line.trim().to_string())
    .filter(|line| !line.is_empty())
    .collect::<Vec<_>>();
    let commit_count = messages.len();
    if commit_count == 0 {
        return None;
    }
    let revert_count = messages
        .iter()
        .filter(|msg| {
            let msg = msg.to_lowercase();
            msg.contains("revert") || msg.contains("rollback") || msg.contains("undo")
        })
        .count();

    Some(ChurnMetrics {
        commit_count,
        revert_count,
    })
}

fn churn_reasons(metrics: &ChurnMetrics) -> Vec<String> {
    let mut reasons = Vec::new();
    if metrics.commit_count >= 8 {
        reasons.push(format!(
            "high churn ({} commits touched this file)",
            metrics.commit_count
        ));
    }
    if metrics.revert_count >= 1 && metrics.commit_count >= 3 {
        reasons.push(format!(
            "rework-heavy file ({} commits, {} revert-like commits)",
            metrics.commit_count, metrics.revert_count
        ));
    }
    reasons
}

fn git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
    let output = Command::new("git")
        .current_dir(repo_root)
        .args(args)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8(output.stdout).ok()
}

fn repo_relative_path(repo_root: &Path, file_path: &str) -> PathBuf {
    let path = Path::new(file_path);
    if let Ok(stripped) = path.strip_prefix(repo_root) {
        stripped.to_path_buf()
    } else {
        path.to_path_buf()
    }
}

fn is_utility_surface(file: &FileRecord) -> bool {
    !file.methods.is_empty()
        && file
            .methods
            .iter()
            .all(|method| is_utility_helper_name(&method.name))
}

pub(crate) fn churn_flags(file_records: &[FileRecord], repo_root: &Path) -> Vec<StaticFlag> {
    let mut flags = Vec::new();

    for file in file_records {
        if crate::roles::is_intentional_surface_record(file) {
            continue;
        }

        if is_utility_surface(file) {
            continue;
        }

        let Some(metrics) = collect_churn_metrics(repo_root, &file.file_path) else {
            continue;
        };
        let reasons = churn_reasons(&metrics);
        if !reasons.is_empty() {
            flags.push(similarity::make_file_flag(
                &file.file_path,
                reasons.join("; "),
                FindingTier::KindaSlop,
                "churn",
            ));
        }
    }

    flags
}