repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use crate::review::signals::behavioral::{
    BehavioralKind, BehavioralSignal, BehavioralSignalSource, DependencyContext, truncate_str,
};
use tree_sitter::Node;

pub(super) fn match_python(
    node: Node<'_>,
    content: &str,
    path_str: &str,
    line: usize,
    dependencies: &DependencyContext,
) -> Option<BehavioralSignal> {
    match node.kind() {
        "call" => {
            let callee = node.child_by_field_name("function")?;
            let callee_text = callee.utf8_text(content.as_bytes()).ok()?.trim();
            let text = node.utf8_text(content.as_bytes()).unwrap_or("");

            if callee_text.starts_with("requests.")
                || callee_text.starts_with("urllib.")
                || callee_text.starts_with("httpx.")
                || callee_text == "urlopen"
                || callee_text == "socket.connect"
                || callee_text.starts_with("aiohttp.")
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::NetworkCallAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if callee_text.starts_with("subprocess.")
                || callee_text == "os.system"
                || callee_text == "os.popen"
                || callee_text == "sh.run"
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::SubprocessAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if callee_text.starts_with("pathlib.") && callee_text.contains("write") {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::FsWriteAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if callee_text == "open"
                && let Some(args) = node.child_by_field_name("arguments")
            {
                let args_text = args.utf8_text(content.as_bytes()).unwrap_or("");
                if args_text.contains("'w'")
                    || args_text.contains("\"w\"")
                    || args_text.contains("'wb'")
                    || args_text.contains("\"wb\"")
                    || args_text.contains("'a'")
                    || args_text.contains("\"a\"")
                    || args_text.contains("'ab'")
                    || args_text.contains("\"ab\"")
                {
                    return Some(BehavioralSignal {
                        kind: BehavioralKind::FsWriteAdded,
                        path: path_str.to_string(),
                        line,
                        detail: truncate_str(text, 60),
                        source: BehavioralSignalSource::Ast,
                    });
                }
            }
            if callee_text.ends_with(".execute") || callee_text.ends_with(".query") {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::RawSqlAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        "attribute" | "subscript" => {
            let text = node.utf8_text(content.as_bytes()).ok()?.trim();
            if text == "os.environ"
                || text.starts_with("os.environ[")
                || text.starts_with("os.environ.get")
                || text.starts_with("os.getenv")
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::EnvVarIntroduced,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        "import_statement" | "import_from_statement" => {
            let text = node.utf8_text(content.as_bytes()).ok()?.trim();
            if let Some(root) = imported_python_root(text)
                && !PYTHON_STDLIB.contains(&root)
                && !dependencies.is_local_package(root)
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::DependencyImportAdded,
                    path: path_str.to_string(),
                    line,
                    detail: format!("Imported module: {text}"),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        _ => {}
    }
    None
}

const PYTHON_STDLIB: &[&str] = &[
    "argparse",
    "asyncio",
    "collections",
    "contextlib",
    "csv",
    "dataclasses",
    "datetime",
    "enum",
    "functools",
    "hashlib",
    "http",
    "importlib",
    "io",
    "itertools",
    "json",
    "logging",
    "math",
    "os",
    "pathlib",
    "re",
    "shutil",
    "socket",
    "sqlite3",
    "subprocess",
    "sys",
    "tempfile",
    "threading",
    "time",
    "typing",
    "unittest",
    "urllib",
    "uuid",
];

fn imported_python_root(text: &str) -> Option<&str> {
    let text = text.trim();
    if let Some(rest) = text.strip_prefix("from ") {
        let root = rest.split_whitespace().next()?.split('.').next()?;
        return (!root.is_empty() && root != ".").then_some(root);
    }
    let rest = text.strip_prefix("import ")?;
    let root = rest.split([',', ' ', '.']).next()?;
    (!root.is_empty() && root != ".").then_some(root)
}