deslop 0.2.0

A static analyzer that spots low-context and AI-assisted code patterns across naming, concurrency, security, performance, and test quality.
Documentation
use crate::analysis::{ParsedFile, ParsedFunction};
use crate::model::{Finding, Severity, SymbolKind};

pub(crate) const BINDING_LOCATION: &str = file!();

pub(super) fn textbook_docstring_findings(
    file: &ParsedFile,
    function: &ParsedFunction,
) -> Vec<Finding> {
    if function.is_test_function || function.fingerprint.receiver_type.is_some() {
        return Vec::new();
    }

    let Some(doc_comment) = function.doc_comment.as_deref() else {
        return Vec::new();
    };
    if function.fingerprint.line_count > 10
        || function.fingerprint.complexity_score > 1
        || function.fingerprint.call_count > 2
    {
        return Vec::new();
    }

    let doc_word_count = doc_comment.split_whitespace().count();
    if doc_word_count < 10 || doc_comment.lines().count() < 2 {
        return Vec::new();
    }

    vec![Finding {
        rule_id: "textbook_docstring_small_helper".to_string(),
        severity: Severity::Info,
        path: file.path.clone(),
        function_name: Some(function.fingerprint.name.clone()),
        start_line: function.fingerprint.start_line,
        end_line: function.fingerprint.start_line,
        message: format!(
            "function {} has a textbook-style docstring for a very small helper",
            function.fingerprint.name
        ),
        evidence: vec![
            format!("doc_word_count={doc_word_count}"),
            format!("line_count={}", function.fingerprint.line_count),
            format!("complexity_score={}", function.fingerprint.complexity_score),
        ],
    }]
}

pub(super) fn mixed_naming_convention_findings(file: &ParsedFile) -> Vec<Finding> {
    let mut snake_names = Vec::new();
    let mut camel_names = Vec::new();

    for symbol in &file.symbols {
        if !matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
            continue;
        }

        match naming_style(&symbol.name) {
            Some(NamingStyle::Snake) => snake_names.push((symbol.line, symbol.name.clone())),
            Some(NamingStyle::Camel) => camel_names.push((symbol.line, symbol.name.clone())),
            None => {}
        }
    }

    if snake_names.len() < 2 || camel_names.len() < 2 {
        return Vec::new();
    }

    let start_line = snake_names[0].0.min(camel_names[0].0);
    vec![Finding {
        rule_id: "mixed_naming_conventions".to_string(),
        severity: Severity::Info,
        path: file.path.clone(),
        function_name: None,
        start_line,
        end_line: start_line,
        message: "file mixes snake_case and camelCase function naming conventions".to_string(),
        evidence: vec![
            format!(
                "snake_case examples: {}",
                snake_names
                    .iter()
                    .take(3)
                    .map(|(_, name)| name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            format!(
                "camelCase examples: {}",
                camel_names
                    .iter()
                    .take(3)
                    .map(|(_, name)| name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
        ],
    }]
}

pub(super) fn unrelated_heavy_import_findings(file: &ParsedFile) -> Vec<Finding> {
    if file.is_test_file {
        return Vec::new();
    }

    let heavy_imports = file
        .imports
        .iter()
        .filter(|import| is_heavy_ecosystem(&import.path))
        .collect::<Vec<_>>();
    if heavy_imports.len() < 2 {
        return Vec::new();
    }

    let used_aliases = file
        .functions
        .iter()
        .flat_map(|function| function.calls.iter())
        .filter_map(|call| call.receiver.as_deref().or(Some(call.name.as_str())))
        .collect::<std::collections::BTreeSet<_>>();
    let unused_heavy_imports = heavy_imports
        .iter()
        .filter(|import| !used_aliases.contains(import.alias.as_str()))
        .collect::<Vec<_>>();
    if unused_heavy_imports.is_empty() {
        return Vec::new();
    }

    vec![Finding {
        rule_id: "unrelated_heavy_import".to_string(),
        severity: Severity::Info,
        path: file.path.clone(),
        function_name: None,
        start_line: 1,
        end_line: 1,
        message: "file imports heavy ecosystems that show little local evidence of need"
            .to_string(),
        evidence: unused_heavy_imports
            .iter()
            .take(3)
            .map(|import| format!("unused heavy import: {} as {}", import.path, import.alias))
            .collect(),
    }]
}

pub(super) fn obvious_commentary_findings(file: &ParsedFile) -> Vec<Finding> {
    if file.is_test_file {
        return Vec::new();
    }

    let comments = file
        .comments
        .iter()
        .filter(|comment| looks_like_obvious_comment(&comment.text))
        .collect::<Vec<_>>();
    if comments.len() < 2 {
        return Vec::new();
    }

    vec![Finding {
        rule_id: "obvious_commentary".to_string(),
        severity: Severity::Info,
        path: file.path.clone(),
        function_name: None,
        start_line: comments[0].line,
        end_line: comments[0].line,
        message: "file contains comments that narrate obvious implementation steps".to_string(),
        evidence: comments
            .iter()
            .take(3)
            .map(|comment| format!("line {}: {}", comment.line, comment.text))
            .collect(),
    }]
}

pub(super) fn enthusiastic_commentary_findings(file: &ParsedFile) -> Vec<Finding> {
    if file.is_test_file {
        return Vec::new();
    }

    let comments = file
        .comments
        .iter()
        .filter(|comment| has_enthusiastic_tone(&comment.text))
        .collect::<Vec<_>>();
    if comments.is_empty() {
        return Vec::new();
    }

    vec![Finding {
        rule_id: "enthusiastic_commentary".to_string(),
        severity: Severity::Info,
        path: file.path.clone(),
        function_name: None,
        start_line: comments[0].line,
        end_line: comments[0].line,
        message: "file contains unusually enthusiastic or emoji-heavy production comments"
            .to_string(),
        evidence: comments
            .iter()
            .take(3)
            .map(|comment| format!("line {}: {}", comment.line, comment.text))
            .collect(),
    }]
}

enum NamingStyle {
    Snake,
    Camel,
}

fn naming_style(name: &str) -> Option<NamingStyle> {
    if name.starts_with('_')
        || name
            .chars()
            .all(|character| !character.is_ascii_alphabetic())
    {
        return None;
    }
    if name
        .chars()
        .all(|character| !character.is_ascii_uppercase())
        && name.contains('_')
    {
        return Some(NamingStyle::Snake);
    }

    let mut characters = name.chars();
    let first = characters.next()?;
    if first.is_ascii_lowercase() && characters.any(|character| character.is_ascii_uppercase()) {
        return Some(NamingStyle::Camel);
    }

    None
}

fn is_heavy_ecosystem(path: &str) -> bool {
    [
        "tensorflow",
        "torch",
        "pandas",
        "numpy",
        "sqlalchemy",
        "django",
        "flask",
        "fastapi",
        "sklearn",
    ]
    .iter()
    .any(|prefix| path.starts_with(prefix))
}

fn looks_like_obvious_comment(text: &str) -> bool {
    let normalized = text.trim().to_ascii_lowercase();
    [
        "set ",
        "increment ",
        "append ",
        "return ",
        "check ",
        "loop ",
        "create ",
        "initialize ",
        "call ",
    ]
    .iter()
    .any(|prefix| normalized.starts_with(prefix))
}

fn has_enthusiastic_tone(text: &str) -> bool {
    text.contains('!')
        || text.contains(":)")
        || text.contains(":D")
        || text
            .chars()
            .any(|character| matches!(character, '🎉' | '✨' | '🔥' | '🚀' | '💥'))
}