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 super::*;

pub(crate) fn llm_findings(file: &ParsedFile, function: &ParsedFunction) -> Vec<Finding> {
    if function.is_test_function {
        return Vec::new();
    }
    let body = &function.body_text;
    let has_llm = has_any_import(
        file,
        &["openai", "anthropic", "langchain", "litellm", "cohere"],
    );
    if !has_llm {
        return Vec::new();
    }
    let mut findings = Vec::new();

    let llm_call_patterns = &[
        "ChatCompletion.create(",
        "chat.completions.create(",
        "messages.create(",
        "Completion.create(",
        "client.chat(",
        "llm(",
        "chain(",
    ];

    let lines: Vec<&str> = body.lines().collect();
    let mut loop_indent: Option<usize> = None;
    for (i, line) in lines.iter().enumerate() {
        let trimmed = line.trim();
        if trimmed.starts_with("for ") && trimmed.ends_with(':') {
            loop_indent = Some(indent_level(line));
            continue;
        }
        if loop_indent.is_some() && !trimmed.is_empty() {
            for pattern in llm_call_patterns {
                if trimmed.contains(pattern) {
                    findings.push(make_finding(
                        "llm_api_call_in_loop_without_batching",
                        Severity::Warning,
                        file,
                        function,
                        function.fingerprint.start_line + i,
                        "calls LLM API inside a loop; batch requests to reduce cost and latency",
                    ));
                    break;
                }
            }
            if (trimmed.contains("prompt +=") || trimmed.contains("prompt = prompt +"))
                && (trimmed.contains('"') || trimmed.contains('\''))
            {
                findings.push(make_finding(
                    "prompt_template_string_concat_in_loop",
                    Severity::Info,
                    file,
                    function,
                    function.fingerprint.start_line + i,
                    "concatenates prompt string inside a loop; build template once",
                ));
            }
        }
        if let Some(li) = loop_indent
            && !trimmed.is_empty()
            && indent_level(line) <= li
            && !trimmed.starts_with('#')
        {
            loop_indent = None;
        }
    }

    for (i, line) in body.lines().enumerate() {
        let trimmed = line.trim();
        if (trimmed.contains("api_key")
            || trimmed.contains("API_KEY")
            || trimmed.contains("OPENAI_API_KEY"))
            && trimmed.contains(" = ")
            && (trimmed.contains("\"sk-")
                || trimmed.contains("'sk-")
                || trimmed.contains("\"key-")
                || trimmed.contains("'key-"))
        {
            findings.push(make_finding(
                "hardcoded_api_key_in_source",
                Severity::Warning,
                file,
                function,
                function.fingerprint.start_line + i,
                "hardcodes API key in source; use environment variables or secret management",
            ));
        }
    }

    if body.contains("retry") || body.contains("RateLimitError") || body.contains("rate_limit") {
        let has_backoff = body.contains("backoff")
            || body.contains("exponential")
            || body.contains("Retry-After");
        if !has_backoff
            && body.contains("sleep(")
            && let Some(line) = find_line(body, "sleep(", function.fingerprint.start_line)
        {
            findings.push(make_finding(
                "retry_on_rate_limit_without_backoff",
                Severity::Info,
                file,
                function,
                line,
                "retries without exponential backoff; implement backoff to respect rate limits",
            ));
        }
    }

    for pattern in llm_call_patterns {
        if body.contains(pattern)
            && !body.contains("token")
            && !body.contains("tiktoken")
            && !body.contains("count_tokens")
        {
            if let Some(line) = find_line(body, pattern, function.fingerprint.start_line) {
                findings.push(make_finding(
                    "token_count_not_checked_before_api_call",
                    Severity::Info,
                    file,
                    function,
                    line,
                    "sends prompt to LLM without token counting; risk of context window overflow",
                ));
            }
            break;
        }
    }

    if is_handler_or_view(function)
        && has_any_import(
            file,
            &[
                "qdrant_client",
                "chromadb",
                "pinecone",
                "weaviate",
                "faiss",
                "lancedb",
                "milvus",
            ],
        )
    {
        for pattern in [
            "QdrantClient(",
            "PersistentClient(",
            "chromadb.Client(",
            "Pinecone(",
            "weaviate.Client(",
            "faiss.Index",
            "lancedb.connect(",
            "MilvusClient(",
        ] {
            if let Some(line) = find_line(body, pattern, function.fingerprint.start_line) {
                findings.push(make_finding(
                    "vector_store_client_created_per_request",
                    Severity::Warning,
                    file,
                    function,
                    line,
                    "creates a vector-store client on a request path; reuse the client across requests",
                ));
                break;
            }
        }
    }

    if is_handler_or_view(function)
        && has_any_import(file, &["langchain", "langchain_core", "llama_index"])
    {
        for pattern in [
            "LLMChain(",
            "RetrievalQA.from_chain_type(",
            "ChatPromptTemplate.from_template(",
            "PromptTemplate.from_template(",
            "VectorStoreIndex.from_documents(",
            "ServiceContext.from_defaults(",
        ] {
            if let Some(line) = find_line(body, pattern, function.fingerprint.start_line) {
                findings.push(make_finding(
                    "langchain_chain_built_per_request",
                    Severity::Info,
                    file,
                    function,
                    line,
                    "builds a LangChain/LlamaIndex-style chain on a request path; cache reusable prompt and chain wiring",
                ));
                break;
            }
        }
    }

    if has_any_import(file, &["transformers", "tokenizers", "tiktoken"]) {
        let lines: Vec<&str> = body.lines().collect();
        let mut loop_indent: Option<usize> = None;
        let has_cache_signal = body.contains("cache")
            || body.contains("lru_cache")
            || body.contains("@cache")
            || body.contains("batch_encode_plus(")
            || body.contains("encode_batch(");

        for (i, line) in lines.iter().enumerate() {
            let trimmed = line.trim();
            if trimmed.starts_with("for ") && trimmed.ends_with(':') {
                loop_indent = Some(indent_level(line));
                continue;
            }
            if loop_indent.is_some()
                && !has_cache_signal
                && (trimmed.contains("tokenizer.encode(")
                    || trimmed.contains("encoding.encode(")
                    || trimmed.contains(".encode(") && trimmed.contains("token"))
            {
                findings.push(make_finding(
                    "tokenizer_encode_in_loop_without_cache",
                    Severity::Info,
                    file,
                    function,
                    function.fingerprint.start_line + i,
                    "encodes tokens inside a loop without caching or batching; reuse token counts or batch the call",
                ));
                break;
            }
            if let Some(li) = loop_indent
                && !trimmed.is_empty()
                && indent_level(line) <= li
                && !trimmed.starts_with('#')
            {
                loop_indent = None;
            }
        }
    }

    findings
}