use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{deterministic_finding_id, Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use tracing::info;
static DEBUG_PATTERN: OnceLock<Regex> = OnceLock::new();
fn debug_pattern() -> &'static Regex {
DEBUG_PATTERN.get_or_init(|| Regex::new(r"(?i)(console\.(log|debug|info|warn)|print\(|debugger;?|debug\s*=\s*True|DEBUG\s*=\s*true|binding\.pry|byebug|import\s+pdb|pdb\.set_trace)").expect("valid regex"))
}
pub struct DebugCodeDetector {
repository_path: PathBuf,
max_findings: usize,
}
impl DebugCodeDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self {
repository_path: repository_path.into(),
max_findings: 100,
}
}
fn is_logging_utility(func_name: &str) -> bool {
let logging_patterns = [
"log", "debug", "trace", "print", "dump", "inspect", "show", "display",
];
let name_lower = func_name.to_lowercase();
logging_patterns.iter().any(|p| name_lower.contains(p))
}
fn is_dev_only_path(path: &str) -> bool {
let dev_patterns = [
"/dev/",
"/debug/",
"/utils/debug",
"/helpers/debug",
"debug_",
"_debug.",
"/logging/",
];
dev_patterns.iter().any(|p| path.contains(p))
}
fn find_containing_function(
graph: &dyn crate::graph::GraphQuery,
file_path: &str,
line: u32,
) -> Option<String> {
graph
.get_functions()
.into_iter()
.find(|f| f.file_path == file_path && f.line_start <= line && f.line_end >= line)
.map(|f| f.name)
}
}
impl Detector for DebugCodeDetector {
fn name(&self) -> &'static str {
"debug-code"
}
fn description(&self) -> &'static str {
"Detects debug statements left in code"
}
fn detect(&self, graph: &dyn crate::graph::GraphQuery) -> Result<Vec<Finding>> {
let mut findings = vec![];
let mut debug_per_file: HashMap<String, usize> = HashMap::new();
let walker = ignore::WalkBuilder::new(&self.repository_path)
.hidden(false)
.git_ignore(true)
.build();
for entry in walker.filter_map(|e| e.ok()) {
if findings.len() >= self.max_findings {
break;
}
let path = entry.path();
if !path.is_file() {
continue;
}
let path_str = path.to_string_lossy().to_string();
if crate::detectors::base::is_test_path(&path_str) || path_str.contains("spec") {
continue;
}
if Self::is_dev_only_path(&path_str) {
continue;
}
if crate::detectors::content_classifier::is_non_production_path(&path_str) {
continue;
}
if path_str.contains("/examples/")
|| path_str.contains("/example/")
|| path_str.contains("/docs/")
|| path_str.contains("/documentation/")
{
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "py" | "js" | "ts" | "jsx" | "tsx" | "rb" | "java") {
continue;
}
if let Some(content) = crate::cache::global_cache().get_content(path) {
let mut file_debug_count = 0;
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("#") {
continue;
}
if debug_pattern().is_match(line) {
let line_num = (i + 1) as u32;
let containing_func =
Self::find_containing_function(graph, &path_str, line_num);
if let Some(ref func) = containing_func {
if Self::is_logging_utility(func) {
continue;
}
}
file_debug_count += 1;
let severity = if line.contains("pdb")
|| line.contains("debugger")
|| line.contains("binding.pry")
{
Severity::High } else if file_debug_count > 5 {
Severity::Medium } else {
Severity::Low
};
let mut notes = Vec::new();
if let Some(func) = &containing_func {
notes.push(format!("📦 In function: `{}`", func));
}
if file_debug_count > 1 {
notes.push(format!(
"📊 {} debug statements in this file so far",
file_debug_count
));
}
let context_notes = if notes.is_empty() {
String::new()
} else {
format!("\n\n**Analysis:**\n{}", notes.join("\n"))
};
let suggestion = if line.contains("print") {
"Replace with proper logging:\n\
```python\n\
import logging\n\
logger = logging.getLogger(__name__)\n\
logger.debug('message') # Only shows in debug mode\n\
```"
.to_string()
} else if line.contains("console.log") {
"Remove or replace with a logging library that can be disabled:\n\
```javascript\n\
import debug from 'debug';\n\
const log = debug('app:module');\n\
log('message'); // Only shows when DEBUG=app:*\n\
```"
.to_string()
} else {
"Remove debug code or replace with proper logging.".to_string()
};
findings.push(Finding {
id: String::new(),
detector: "DebugCodeDetector".to_string(),
severity,
title: if line.contains("debugger") || line.contains("pdb") {
"Interactive debugger left in code".to_string()
} else {
"Debug code left in".to_string()
},
description: format!(
"Debug statements should be removed before production.{}",
context_notes
),
affected_files: vec![path.to_path_buf()],
line_start: Some(line_num),
line_end: Some(line_num),
suggested_fix: Some(suggestion),
estimated_effort: Some("5 minutes".to_string()),
category: Some("code-quality".to_string()),
cwe_id: Some("CWE-489".to_string()),
why_it_matters: Some(
"Debug code can leak sensitive information, clutter logs, \
and interactive debuggers will hang the application."
.to_string(),
),
..Default::default()
});
}
}
if file_debug_count > 0 {
debug_per_file.insert(path_str, file_debug_count);
}
}
}
info!(
"DebugCodeDetector found {} findings (graph-aware)",
findings.len()
);
Ok(findings)
}
}