repopilot 0.18.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,
    extract_string_literal, is_local_import, truncate_str,
};
use tree_sitter::Node;

pub(super) fn match_js(
    node: Node<'_>,
    content: &str,
    path_str: &str,
    line: usize,
    dependencies: &DependencyContext,
) -> Option<BehavioralSignal> {
    match node.kind() {
        "call_expression" => {
            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 == "fetch"
                || callee_text.starts_with("axios")
                || callee_text.starts_with("http.request")
                || callee_text.starts_with("https.request")
                || callee_text.starts_with("http.get")
                || callee_text.starts_with("https.get")
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::NetworkCallAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if callee_text == "exec"
                || callee_text == "spawn"
                || callee_text == "fork"
                || callee_text.starts_with("child_process.")
                || callee_text.contains("execSync")
                || callee_text.contains("spawnSync")
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::SubprocessAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if callee_text.contains("writeFile")
                || callee_text.contains("writeFileSync")
                || callee_text.contains("appendFile")
                || callee_text.contains("appendFileSync")
                || callee_text.contains("createWriteStream")
                || (callee_text.starts_with("fs.")
                    && (callee_text.contains("write") || callee_text.contains("append")))
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::FsWriteAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
            if (callee_text == "require" || callee_text == "import")
                && let Some(args) = node.child_by_field_name("arguments")
            {
                let arg_text = args.utf8_text(content.as_bytes()).ok()?;
                if let Some(val) = extract_string_literal(arg_text.trim())
                    && is_external_js_import(val, dependencies)
                {
                    return Some(BehavioralSignal {
                        kind: BehavioralKind::DependencyImportAdded,
                        path: path_str.to_string(),
                        line,
                        detail: format!("Imported dependency '{val}'"),
                        source: BehavioralSignalSource::Ast,
                    });
                }
            }
            if callee_text.ends_with(".query")
                || callee_text == "query"
                || callee_text.ends_with(".execute")
                || callee_text == "execute"
            {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::RawSqlAdded,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        "new_expression" => {
            let constructor = node.child_by_field_name("constructor")?;
            let name = constructor.utf8_text(content.as_bytes()).ok()?.trim();
            if name == "WebSocket" {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::NetworkCallAdded,
                    path: path_str.to_string(),
                    line,
                    detail: "new WebSocket(...)".to_string(),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        "member_expression" => {
            let text = node.utf8_text(content.as_bytes()).ok()?.trim();
            if text.starts_with("process.env") {
                return Some(BehavioralSignal {
                    kind: BehavioralKind::EnvVarIntroduced,
                    path: path_str.to_string(),
                    line,
                    detail: truncate_str(text, 60),
                    source: BehavioralSignalSource::Ast,
                });
            }
        }
        "import_statement" | "export_statement" => {
            if let Some(source) = node.child_by_field_name("source") {
                let text = source.utf8_text(content.as_bytes()).ok()?.trim();
                if let Some(val) = extract_string_literal(text)
                    && is_external_js_import(val, dependencies)
                {
                    return Some(BehavioralSignal {
                        kind: BehavioralKind::DependencyImportAdded,
                        path: path_str.to_string(),
                        line,
                        detail: format!("Imported dependency '{val}'"),
                        source: BehavioralSignalSource::Ast,
                    });
                }
            }
        }
        _ => {}
    }
    None
}

fn is_external_js_import(path: &str, dependencies: &DependencyContext) -> bool {
    const NODE_BUILTINS: &[&str] = &[
        "assert",
        "buffer",
        "child_process",
        "crypto",
        "events",
        "fs",
        "http",
        "https",
        "module",
        "net",
        "os",
        "path",
        "process",
        "stream",
        "string_decoder",
        "timers",
        "tls",
        "tty",
        "url",
        "util",
        "v8",
        "vm",
        "worker_threads",
        "zlib",
    ];
    if is_local_import(path) || path.starts_with("node:") || dependencies.is_local_package(path) {
        return false;
    }
    let root = path.split('/').next().unwrap_or(path);
    !NODE_BUILTINS.contains(&root)
}