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 std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use crate::analysis::{
    DeclaredSymbol, Language, ParsedFile, ParsedFunction, RustModuleDeclaration,
};
use crate::model::SymbolKind;

use super::{PackageIndex, PackageKey, RepositoryIndex};

type RustChildModules = BTreeMap<PathBuf, BTreeMap<String, Vec<PathBuf>>>;
type RustParentModules = BTreeMap<PathBuf, Vec<PathBuf>>;
type RustCrateRoots = BTreeMap<PathBuf, Vec<PathBuf>>;

pub(crate) fn build_repository_index(root: &Path, files: &[ParsedFile]) -> RepositoryIndex {
    let mut packages = BTreeMap::new();
    let mut rust_package_names_by_file = BTreeMap::new();
    let mut rust_imports_by_file = BTreeMap::new();

    for file in files {
        let ParsedFile {
            language,
            path,
            package_name,
            imports,
            symbols,
            ..
        } = file;
        let language = *language;
        let package_name = package_name
            .clone()
            .unwrap_or_else(|| "unknown".to_string());
        if language == Language::Rust {
            rust_package_names_by_file.insert(path.clone(), package_name.clone());
            rust_imports_by_file.insert(path.clone(), imports.clone());
        }
        let directory = package_directory(root, path);
        let import_count = imports.len();
        let key = PackageKey {
            language,
            package_name: package_name.clone(),
            directory: directory.clone(),
        };
        let package_entry = packages.entry(key).or_insert_with(|| PackageIndex {
            language,
            package_name,
            directory,
            functions: BTreeSet::new(),
            contextless_wrapper_functions: BTreeSet::new(),
            methods_by_receiver: BTreeMap::new(),
            symbols: Vec::new(),
            import_count: 0,
        });

        package_entry.import_count += import_count;

        for function in &file.functions {
            if function.fingerprint.receiver_type.is_none()
                && is_contextless_wrapper_candidate(file, function)
            {
                package_entry
                    .contextless_wrapper_functions
                    .insert(function.fingerprint.name.clone());
            }
        }

        for symbol in symbols {
            insert_symbol(package_entry, symbol);
        }
    }

    let (rust_child_modules, rust_parent_modules, rust_crate_roots) =
        build_rust_module_graph(files);

    RepositoryIndex {
        root: root.to_path_buf(),
        packages,
        rust_package_names_by_file,
        rust_imports_by_file,
        rust_child_modules,
        rust_parent_modules,
        rust_crate_roots,
    }
}

pub(super) fn package_directory(root: &Path, file_path: &Path) -> PathBuf {
    let Some(parent) = file_path.parent() else {
        return PathBuf::new();
    };

    if root.as_os_str().is_empty() {
        return parent.to_path_buf();
    }

    parent
        .strip_prefix(root)
        .map(Path::to_path_buf)
        .unwrap_or_else(|_| parent.to_path_buf())
}

fn insert_symbol(package_entry: &mut PackageIndex, symbol: &DeclaredSymbol) {
    package_entry.symbols.push(symbol.clone());

    match symbol.kind {
        SymbolKind::Function => {
            package_entry.functions.insert(symbol.name.clone());
        }
        SymbolKind::Method => {
            if let Some(receiver) = &symbol.receiver_type {
                package_entry
                    .methods_by_receiver
                    .entry(receiver.clone())
                    .or_default()
                    .insert(symbol.name.clone());
            }
        }
        _ => {}
    }
}

fn build_rust_module_graph(
    files: &[ParsedFile],
) -> (RustChildModules, RustParentModules, RustCrateRoots) {
    let rust_files = files
        .iter()
        .filter(|file| file.language == Language::Rust)
        .map(|file| file.path.clone())
        .collect::<BTreeSet<_>>();
    let mut child_modules = RustChildModules::new();
    let mut parent_modules = RustParentModules::new();

    for file in files.iter().filter(|file| file.language == Language::Rust) {
        for declaration in file.rust_module_declarations() {
            let resolved_paths = resolve_module_declaration_paths(&file.path, declaration);
            for resolved_path in resolved_paths {
                if !rust_files.contains(&resolved_path) {
                    continue;
                }
                child_modules
                    .entry(file.path.clone())
                    .or_default()
                    .entry(declaration.name.clone())
                    .or_default()
                    .push(resolved_path.clone());
                parent_modules
                    .entry(resolved_path)
                    .or_default()
                    .push(file.path.clone());
            }
        }
    }

    for children in child_modules.values_mut() {
        for paths in children.values_mut() {
            paths.sort();
            paths.dedup();
        }
    }
    for parents in parent_modules.values_mut() {
        parents.sort();
        parents.dedup();
    }

    let child_files = parent_modules.keys().cloned().collect::<BTreeSet<_>>();
    let roots = rust_files
        .iter()
        .filter(|path| !child_files.contains(*path))
        .cloned()
        .collect::<Vec<_>>();

    let mut crate_roots = RustCrateRoots::new();
    for root in &roots {
        let mut visited = BTreeSet::new();
        assign_crate_root(root, root, &child_modules, &mut crate_roots, &mut visited);
    }
    for roots in crate_roots.values_mut() {
        roots.sort();
        roots.dedup();
    }

    (child_modules, parent_modules, crate_roots)
}

fn assign_crate_root(
    root: &Path,
    current: &Path,
    child_modules: &BTreeMap<PathBuf, BTreeMap<String, Vec<PathBuf>>>,
    crate_roots: &mut BTreeMap<PathBuf, Vec<PathBuf>>,
    visited: &mut BTreeSet<PathBuf>,
) {
    if !visited.insert(current.to_path_buf()) {
        return;
    }

    crate_roots
        .entry(current.to_path_buf())
        .or_default()
        .push(root.to_path_buf());

    if let Some(children) = child_modules.get(current) {
        for paths in children.values() {
            for path in paths {
                assign_crate_root(root, path, child_modules, crate_roots, visited);
            }
        }
    }
}

fn resolve_module_declaration_paths(
    parent_file_path: &Path,
    declaration: &RustModuleDeclaration,
) -> Vec<PathBuf> {
    let Some(parent_dir) = parent_file_path.parent() else {
        return Vec::new();
    };

    if let Some(path_override) = &declaration.path_override {
        return vec![normalize_path(&parent_dir.join(path_override))];
    }

    let mut candidates = Vec::new();
    if is_directory_root_module(parent_file_path) {
        candidates.push(normalize_path(
            &parent_dir.join(format!("{}.rs", declaration.name)),
        ));
        candidates.push(normalize_path(
            &parent_dir.join(&declaration.name).join("mod.rs"),
        ));
        return candidates;
    }

    let Some(stem) = parent_file_path.file_stem().and_then(|stem| stem.to_str()) else {
        return Vec::new();
    };
    let module_dir = parent_dir.join(stem);
    candidates.push(normalize_path(
        &module_dir.join(format!("{}.rs", declaration.name)),
    ));
    candidates.push(normalize_path(
        &module_dir.join(&declaration.name).join("mod.rs"),
    ));
    candidates
}

fn is_directory_root_module(path: &Path) -> bool {
    matches!(
        path.file_name().and_then(|name| name.to_str()),
        Some("mod.rs" | "lib.rs" | "main.rs")
    )
}

fn normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();

    for component in path.components() {
        match component {
            std::path::Component::CurDir => {}
            std::path::Component::ParentDir => {
                normalized.pop();
            }
            _ => normalized.push(component.as_os_str()),
        }
    }

    normalized
}

fn is_contextless_wrapper_candidate(file: &ParsedFile, function: &ParsedFunction) -> bool {
    if function.go_evidence().has_context_parameter {
        return false;
    }

    let go = function.go_evidence();
    let import_aliases = file
        .imports
        .iter()
        .map(|import| (import.alias.as_str(), import.path.as_str()))
        .collect::<BTreeMap<_, _>>();

    function.calls.iter().any(|call| {
        let Some(receiver) = call.receiver.as_deref() else {
            return false;
        };
        let Some(import_path) = import_aliases.get(receiver) else {
            return false;
        };

        matches!(*import_path, "net/http")
            && matches!(
                call.name.as_str(),
                "Get" | "Head" | "Post" | "PostForm" | "NewRequest"
            )
            || matches!(*import_path, "os/exec") && call.name == "Command"
            || matches!(*import_path, "net") && matches!(call.name.as_str(), "Dial" | "DialTimeout")
    }) || go.db_query_calls.iter().any(|query_call| {
        matches!(
            query_call.method_name.as_str(),
            "Query" | "QueryRow" | "Exec" | "Get" | "Select"
        )
    })
}