sniff-cli 0.1.2

An exhaustive LLM-backed slop finder for codebases
Documentation
use crate::report_types::{LLMVerdict, StaticFlag};
use crate::roles::{
    FileRole, classify_file_role, is_detector_support_module, is_module_barrel_module,
    is_pure_reexport_module, is_versioning_tag_module, is_wrapper_only_module,
};
use crate::types::FileRecord;

use super::builder::{FileVerdictBuilder, classify_signal_tier};
use super::signals::is_supporting_only_reason;

fn add_static_flag(builder: &mut FileVerdictBuilder, flag: &StaticFlag) {
    let Some(severity) = classify_signal_tier(flag.tier) else {
        return;
    };

    if flag
        .reasons
        .iter()
        .all(|reason| is_supporting_only_reason(reason))
    {
        return;
    }

    if let Some(method_name) = &flag.method_name {
        builder.add_method_reason(method_name.clone(), severity, flag.reasons.join("; "));
    } else {
        builder.add_file_reason(severity, flag.reasons.join("; "));
    }
}

fn add_llm_verdict(builder: &mut FileVerdictBuilder, verdict: &LLMVerdict) {
    builder.mark_llm_review(verdict.smelly, verdict.method_name.as_deref());
    if !verdict.smelly {
        return;
    }

    let Some(severity) = classify_signal_tier(verdict.tier) else {
        return;
    };

    if verdict.reason.is_empty() || is_supporting_only_reason(&verdict.reason) {
        return;
    }

    if let Some(method_name) = &verdict.method_name {
        let reason = if verdict.evidence.is_empty() {
            verdict.reason.clone()
        } else {
            format!("{} ({})", verdict.reason, verdict.evidence)
        };
        builder.add_method_reason(method_name.clone(), severity, reason);
    } else {
        let reason = if verdict.evidence.is_empty() {
            verdict.reason.clone()
        } else {
            format!("{} ({})", verdict.reason, verdict.evidence)
        };
        builder.add_file_reason(severity, reason);
    }
}

fn seed_builders(
    file_records: &[FileRecord],
) -> std::collections::BTreeMap<String, FileVerdictBuilder> {
    let mut builders = std::collections::BTreeMap::new();

    for file in file_records {
        let role = classify_file_role(&file.file_path);
        let skip_reason = if matches!(role, FileRole::Docs | FileRole::Generated) {
            Some(format!("role={:?}", role))
        } else if is_pure_reexport_module(file) {
            Some("pure_reexport".to_string())
        } else if is_module_barrel_module(file) {
            Some("module_barrel".to_string())
        } else if is_wrapper_only_module(file) {
            Some("wrapper_only".to_string())
        } else if is_detector_support_module(&file.file_path) {
            Some("detector_support".to_string())
        } else if is_versioning_tag_module(&file.file_path) {
            Some("versioning_tag".to_string())
        } else {
            None
        };

        if skip_reason.is_some() {
            continue;
        }

        builders.insert(
            file.file_path.clone(),
            FileVerdictBuilder::new(file.file_path.clone(), role, reviewable_method_count(file)),
        );
    }

    builders
}

fn reviewable_method_count(file: &FileRecord) -> usize {
    if !file.file_path.ends_with(".rs") {
        return file.methods.len();
    }

    let Some(cfg_test_line) = file
        .source
        .lines()
        .position(|line| line.trim().starts_with("#[cfg(test)]"))
        .map(|index| index + 1)
    else {
        return file.methods.len();
    };

    file.methods
        .iter()
        .filter(|method| method.start_line <= cfg_test_line)
        .count()
}

fn apply_static_flags(
    builders: &mut std::collections::BTreeMap<String, FileVerdictBuilder>,
    static_flags: &[StaticFlag],
) {
    for flag in static_flags {
        if let Some(builder) = builders.get_mut(&flag.file_path) {
            add_static_flag(builder, flag);
        }
    }
}

fn apply_llm_verdicts(
    builders: &mut std::collections::BTreeMap<String, FileVerdictBuilder>,
    llm_verdicts: &[LLMVerdict],
) {
    for verdict in llm_verdicts {
        if let Some(builder) = builders.get_mut(&verdict.file_path) {
            add_llm_verdict(builder, verdict);
        }
    }
}

pub fn build_file_verdicts(
    file_records: &[FileRecord],
    static_flags: &[StaticFlag],
    llm_verdicts: &[LLMVerdict],
) -> Vec<crate::report_types::FileVerdict> {
    let mut builders = seed_builders(file_records);
    apply_static_flags(&mut builders, static_flags);
    apply_llm_verdicts(&mut builders, llm_verdicts);

    builders
        .into_values()
        .map(FileVerdictBuilder::finish)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::build_file_verdicts;
    use crate::report_types::LLMVerdict;
    use crate::types::{FileRecord, FindingTier, MethodRecord};

    #[test]
    fn medication_numeric_rules_slop_survives_merge() {
        let file_path =
            "shared/contract/src/commonMain/kotlin/com/pillit/shared/uicontract/MedicationNumericRules.kt"
                .to_string();
        let file = FileRecord {
            file_path: file_path.clone(),
            source: "public fun sanitizeMedicationStrengthInput(value: String): String { return value }\n"
                .to_string(),
            language: "kotlin".to_string(),
            methods: vec![
                MethodRecord {
                    name: "sanitizeMedicationWholeNumberInput".to_string(),
                    file_path: file_path.clone(),
                    source: String::new(),
                    loc: 14,
                    param_count: 1,
                    start_line: 1,
                    end_line: 15,
                    is_exported: true,
                    language: "kotlin".to_string(),
                    nesting_depth: 0,
                    references: vec![],
                    real_ref_count: 0,
                },
                MethodRecord {
                    name: "sanitizeMedicationDecimalInput".to_string(),
                    file_path: file_path.clone(),
                    source: String::new(),
                    loc: 21,
                    param_count: 1,
                    start_line: 17,
                    end_line: 38,
                    is_exported: true,
                    language: "kotlin".to_string(),
                    nesting_depth: 0,
                    references: vec![],
                    real_ref_count: 0,
                },
                MethodRecord {
                    name: "sanitizeMedicationStrengthInput".to_string(),
                    file_path: file_path.clone(),
                    source: String::new(),
                    loc: 33,
                    param_count: 1,
                    start_line: 40,
                    end_line: 72,
                    is_exported: true,
                    language: "kotlin".to_string(),
                    nesting_depth: 0,
                    references: vec![],
                    real_ref_count: 0,
                },
            ],
        };
        let verdict = LLMVerdict {
            verdict_type: "file".to_string(),
            file_path: file_path.clone(),
            method_name: None,
            check_type: "file".to_string(),
            smelly: true,
            tier: FindingTier::Slop,
            cohesive: Some(false),
            name_accurate: Some(false),
            evidence:
                "character == '/' && !slashSeen && currentPartHasDigits && !endsWith(\".\") -> {"
                    .to_string(),
            reason: "function is too big".to_string(),
            loc: 0,
            start_line: 0,
            end_line: 0,
        };

        let file_verdicts = build_file_verdicts(&[file], &[], &[verdict]);
        assert_eq!(file_verdicts.len(), 1);
        assert_eq!(file_verdicts[0].verdict, FindingTier::Slop);
        assert!(
            file_verdicts[0]
                .top_reasons
                .iter()
                .any(|reason| reason.contains("function is too big"))
        );
    }
}