mod csharp;
mod dependency;
mod go;
mod js;
mod jvm;
mod keywords;
mod python;
mod removed;
mod removed_ast;
mod rust;
pub use removed::detect_behavioral_removed;
use crate::review::diff::{ChangeStatus, ChangedFile};
use crate::review::signals::content::ReviewSource;
use serde::Serialize;
use std::collections::BTreeSet;
use std::path::Path;
use tree_sitter::Node;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BehavioralKind {
NetworkCallAdded,
SubprocessAdded,
FsWriteAdded,
EnvVarIntroduced,
DependencyImportAdded,
MigrationAdded,
RawSqlAdded,
ErrorHandlingRemoved,
TestDeletedOrEmptied,
AuthCheckRemoved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BehavioralSignalSource {
Ast,
CoarseFallback,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BehavioralSignal {
pub kind: BehavioralKind,
pub path: String,
pub line: usize,
pub detail: String,
pub source: BehavioralSignalSource,
}
impl BehavioralSignal {
pub fn is_coarse(&self) -> bool {
self.source == BehavioralSignalSource::CoarseFallback
}
}
#[derive(Debug, Default)]
pub struct DependencyContext {
local_package_names: BTreeSet<String>,
local_import_prefixes: BTreeSet<String>,
}
impl DependencyContext {
pub fn from_repo_root(root: &Path) -> Self {
let mut context = Self::default();
for name in dependency::cargo_local_names(root) {
context.add_local_name(&name);
}
for name in dependency::npm_local_names(root) {
context.add_local_name(&name);
}
for prefix in dependency::go_local_prefixes(root) {
context.local_import_prefixes.insert(prefix);
}
context
}
fn add_local_name(&mut self, name: &str) {
let normalized = name.trim().trim_start_matches('@');
if normalized.is_empty() {
return;
}
self.local_package_names.insert(normalized.to_string());
self.local_package_names
.insert(normalized.replace('-', "_"));
}
pub(super) fn is_local_package(&self, name: &str) -> bool {
let normalized = name.trim().trim_start_matches('@');
let root = normalized
.split(['/', ':', '.'])
.next()
.unwrap_or(normalized);
self.local_package_names.contains(normalized)
|| self.local_package_names.contains(root)
|| self
.local_import_prefixes
.iter()
.any(|prefix| normalized == prefix || normalized.starts_with(&format!("{prefix}/")))
}
}
pub fn detect_behavioral_added(
file: &ChangedFile,
post_source: &ReviewSource,
dependencies: &DependencyContext,
) -> Vec<BehavioralSignal> {
let mut signals = Vec::new();
if crate::audits::context::classify::helpers::is_test_file(&file.path) {
return signals;
}
if file.status == ChangeStatus::Added && is_migration_path(&file.path_string()) {
signals.push(BehavioralSignal {
kind: BehavioralKind::MigrationAdded,
path: file.path_string(),
line: 1,
detail: "New database migration script added".to_string(),
source: BehavioralSignalSource::Ast,
});
}
let Some(tree) = post_source.tree() else {
return signals;
};
let content = post_source.content();
let path_str = file.path_string();
let ext = file.path.extension().and_then(|e| e.to_str()).unwrap_or("");
walk_node(
tree.root_node(),
content,
file,
ext,
&path_str,
dependencies,
&mut signals,
);
let mut unique = Vec::new();
for sig in signals {
if !unique.iter().any(|existing: &BehavioralSignal| {
existing.kind == sig.kind && existing.line == sig.line
}) {
unique.push(sig);
}
}
unique
}
fn is_migration_path(path: &str) -> bool {
let path_lower = path.to_lowercase();
path_lower.contains("/migrations/")
|| path_lower.contains("/migrate/")
|| path_lower.contains("/sql/")
|| path_lower.contains("/db/upgrade")
|| path_lower.contains("/database/upgrade")
|| (path_lower.contains("/db/") && path_lower.contains("migration"))
}
fn walk_node(
node: Node<'_>,
content: &str,
file: &ChangedFile,
ext: &str,
path_str: &str,
dependencies: &DependencyContext,
signals: &mut Vec<BehavioralSignal>,
) {
let line = node.start_position().row + 1;
if file.contains_line(line)
&& let Some(signal) = match_node(node, content, ext, path_str, line, dependencies)
{
signals.push(signal);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_node(child, content, file, ext, path_str, dependencies, signals);
}
}
fn match_node(
node: Node<'_>,
content: &str,
ext: &str,
path_str: &str,
line: usize,
dependencies: &DependencyContext,
) -> Option<BehavioralSignal> {
let kind = node.kind();
let is_string = kind.contains("string") || kind == "character_literal";
if is_string && let Ok(text) = node.utf8_text(content.as_bytes()) {
let unquoted = extract_string_literal(text).unwrap_or(text);
if is_sql_query(unquoted) {
return Some(BehavioralSignal {
kind: BehavioralKind::RawSqlAdded,
path: path_str.to_string(),
line,
detail: format!("Raw SQL query: {}", truncate_str(unquoted, 60)),
source: BehavioralSignalSource::Ast,
});
}
}
match ext {
"js" | "mjs" | "cjs" | "ts" | "mts" | "cts" | "tsx" | "jsx" => {
js::match_js(node, content, path_str, line, dependencies)
}
"py" => python::match_python(node, content, path_str, line, dependencies),
"go" => go::match_go(node, content, path_str, line, dependencies),
"rs" => rust::match_rust(node, content, path_str, line, dependencies),
"java" | "kt" | "kts" => jvm::match_jvm(node, content, path_str, line, dependencies),
"cs" => csharp::match_csharp(node, content, path_str, line, dependencies),
_ => None,
}
}
pub(crate) fn extract_string_literal(s: &str) -> Option<&str> {
if let Some(rest) = s.strip_prefix('"') {
let end = rest.find('"')?;
Some(&rest[..end])
} else if let Some(rest) = s.strip_prefix('\'') {
let end = rest.find('\'')?;
Some(&rest[..end])
} else if let Some(rest) = s.strip_prefix('`') {
let end = rest.find('`')?;
Some(&rest[..end])
} else {
None
}
}
pub(crate) fn is_local_import(path: &str) -> bool {
path.starts_with('.')
|| path.starts_with('/')
|| path.starts_with('~')
|| path.starts_with("@/")
}
fn is_sql_query(s: &str) -> bool {
let s_upper = s.to_uppercase();
(s_upper.contains("SELECT ") && s_upper.contains(" FROM "))
|| s_upper.contains("INSERT INTO ")
|| (s_upper.contains("UPDATE ") && s_upper.contains(" SET "))
|| s_upper.contains("DELETE FROM ")
|| s_upper.contains("CREATE TABLE ")
|| s_upper.contains("DROP TABLE ")
}
pub(crate) fn truncate_str(s: &str, max_len: usize) -> String {
let s_trimmed = s.trim().replace('\n', " ");
if s_trimmed.len() <= max_len {
s_trimmed
} else {
format!("{}...", &s_trimmed[..max_len])
}
}