1use crate::models::{Finding, Severity};
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11pub fn finding_id(detector: &str, file: &str, line: u32) -> String {
18 let mut h: u64 = 0xcbf29ce484222325; for b in detector
20 .as_bytes()
21 .iter()
22 .chain(&[0xff]) .chain(file.as_bytes().iter())
24 .chain(&[0xff])
25 .chain(&line.to_le_bytes())
26 {
27 h ^= *b as u64;
28 h = h.wrapping_mul(0x100000001b3); }
30 format!("{:016x}", h)
31}
32use std::collections::HashMap;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum DetectorScope {
38 FileLocal,
40 FileScopedGraph,
43 GraphWide,
46}
47
48#[derive(Debug, Clone)]
50pub struct DetectorResult {
51 pub detector_name: String,
53 pub findings: Vec<Finding>,
55 pub duration_ms: u64,
57 pub success: bool,
59 pub error: Option<String>,
61}
62
63impl DetectorResult {
64 pub fn success(detector_name: String, findings: Vec<Finding>, duration_ms: u64) -> Self {
66 Self {
67 detector_name,
68 findings,
69 duration_ms,
70 success: true,
71 error: None,
72 }
73 }
74
75 pub fn failure(detector_name: String, error: String, duration_ms: u64) -> Self {
77 Self {
78 detector_name,
79 findings: Vec::new(),
80 duration_ms,
81 success: false,
82 error: Some(error),
83 }
84 }
85
86 pub fn skipped(detector_name: &str) -> Self {
88 Self {
89 detector_name: detector_name.to_string(),
90 findings: Vec::new(),
91 duration_ms: 0,
92 success: true,
93 error: None,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct DetectorConfig {
101 #[allow(dead_code)] pub repo_id: Option<String>,
104 pub max_findings: Option<usize>,
106 pub options: HashMap<String, serde_json::Value>,
108 pub coupling_multiplier: f64,
110 pub complexity_multiplier: f64,
112 pub adaptive: crate::calibrate::ThresholdResolver,
114}
115
116impl DetectorConfig {
117 pub fn new() -> Self {
119 Self {
120 repo_id: None,
121 max_findings: None,
122 options: HashMap::new(),
123 coupling_multiplier: 1.0,
124 complexity_multiplier: 1.0,
125 adaptive: crate::calibrate::ThresholdResolver::default(),
126 }
127 }
128
129 pub fn from_project_config(
134 detector_name: &str,
135 project_config: &crate::config::ProjectConfig,
136 ) -> Self {
137 let mut config = Self::new();
138
139 let normalized = crate::config::normalize_detector_name(detector_name);
141
142 if let Some(detector_override) = project_config
144 .detectors
145 .get(&normalized)
146 .or_else(|| project_config.detectors.get(detector_name))
147 {
148 for (key, value) in &detector_override.thresholds {
150 let json_value = match value {
151 crate::config::ThresholdValue::Integer(v) => serde_json::json!(*v),
152 crate::config::ThresholdValue::Float(v) => serde_json::json!(*v),
153 crate::config::ThresholdValue::Boolean(v) => serde_json::json!(*v),
154 crate::config::ThresholdValue::String(v) => serde_json::json!(v),
155 };
156 config.options.insert(key.clone(), json_value);
157 }
158 }
159
160 config
161 }
162
163 pub fn from_project_config_with_type(
168 detector_name: &str,
169 project_config: &crate::config::ProjectConfig,
170 repo_path: &std::path::Path,
171 ) -> Self {
172 let mut config = Self::from_project_config(detector_name, project_config);
173 let project_type = project_config.project_type(repo_path);
174 config.coupling_multiplier = project_type.coupling_multiplier();
175 config.complexity_multiplier = project_type.complexity_multiplier();
176 config
177 }
178
179 pub fn with_adaptive(mut self, resolver: crate::calibrate::ThresholdResolver) -> Self {
181 self.adaptive = resolver;
182 self
183 }
184
185 #[allow(dead_code)] pub fn with_repo_id(mut self, repo_id: impl Into<String>) -> Self {
188 self.repo_id = Some(repo_id.into());
189 self
190 }
191
192 #[allow(dead_code)] pub fn with_max_findings(mut self, max: usize) -> Self {
195 self.max_findings = Some(max);
196 self
197 }
198
199 #[allow(dead_code)] pub fn with_option(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
202 self.options.insert(key.into(), value);
203 self
204 }
205
206 pub fn get_option<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
208 self.options
209 .get(key)
210 .and_then(|v| serde_json::from_value(v.clone()).ok())
211 }
212
213 pub fn get_option_or<T: serde::de::DeserializeOwned>(&self, key: &str, default: T) -> T {
215 self.get_option(key).unwrap_or(default)
216 }
217}
218
219pub fn is_non_production_file(path: &std::path::Path) -> bool {
225 let path_str = path.to_string_lossy().to_lowercase();
226 path_str.contains("/scripts/")
227 || path_str.contains("/benchmarks/")
228 || path_str.contains("/benchmark/")
229 || path_str.contains("/tools/")
230 || path_str.contains("/examples/")
231 || path_str.contains("/example/")
232 || path_str.contains("/docs/")
233 || path_str.contains("/doc/")
234 || path_str.contains("/contrib/")
235 || path_str.contains("/misc/")
236 || path_str.contains("/hack/")
237 || path_str.contains("/utils/") && path_str.contains(".py") || path_str.starts_with("scripts/")
239 || path_str.starts_with("benchmarks/")
240 || path_str.starts_with("tools/")
241 || path_str.starts_with("examples/")
242 || path_str.starts_with("docs/")
243}
244
245pub fn is_test_file(path: &std::path::Path) -> bool {
248 let path_str = path.to_string_lossy().to_lowercase();
249 let filename = path
250 .file_name()
251 .and_then(|s| s.to_str())
252 .unwrap_or("")
253 .to_lowercase();
254
255 path_str.ends_with("_test.go") ||
257 path_str.ends_with("_test.py") ||
259 filename.starts_with("test_") || path_str.contains("/tests/") ||
262 path_str.contains("/test/") ||
263 path_str.contains("/__tests__/") ||
264 path_str.contains("/e2e/") ||
265 path_str.starts_with("tests/") ||
266 path_str.starts_with("test/") ||
267 path_str.starts_with("__tests__/") ||
268 path_str.contains("/spec/") ||
270 path_str.ends_with("_spec.rb") ||
271 path_str.ends_with(".test.ts") ||
272 path_str.ends_with(".test.js") ||
273 path_str.ends_with(".test.tsx") ||
274 path_str.ends_with(".test.jsx") ||
275 path_str.ends_with(".spec.ts") ||
276 path_str.ends_with(".spec.js") ||
277 path_str.ends_with(".spec.tsx") ||
278 path_str.ends_with(".spec.jsx") ||
279 path_str.contains("/fixtures/") ||
281 path_str.contains("/testdata/") ||
282 path_str.contains("/__fixtures__/") ||
283 path_str.contains("/__mocks__/")
284}
285
286pub fn is_test_path(path_str: &str) -> bool {
289 let lower = path_str.to_lowercase();
290 lower.contains("/test/")
291 || lower.contains("/tests/")
292 || lower.contains("/__tests__/")
293 || lower.contains("/spec/")
294 || lower.contains("/test_")
295 || lower.contains("_test.")
296 || lower.contains(".test.")
297 || lower.contains(".spec.")
298 || lower.contains("_spec.")
299 || lower.starts_with("tests/")
301 || lower.starts_with("test/")
302 || lower.starts_with("__tests__/")
303 || lower.starts_with("spec/")
304}
305
306pub trait Detector: Send + Sync {
339 fn name(&self) -> &'static str;
344
345 fn description(&self) -> &'static str;
347
348 fn detect(&self, ctx: &super::analysis_context::AnalysisContext) -> Result<Vec<Finding>>;
361
362 fn is_dependent(&self) -> bool {
369 false
370 }
371
372 #[allow(dead_code)] fn dependencies(&self) -> Vec<&'static str> {
378 vec![]
379 }
380
381 fn category(&self) -> &'static str {
385 "code_smell"
386 }
387
388 fn config(&self) -> Option<&DetectorConfig> {
390 None
391 }
392
393 fn scope(&self) -> DetectorScope {
401 DetectorScope::GraphWide
402 }
403
404 fn detector_scope(&self) -> DetectorScope {
409 if self.requires_graph() {
410 DetectorScope::FileScopedGraph
411 } else {
412 DetectorScope::FileLocal
413 }
414 }
415
416 fn requires_graph(&self) -> bool {
424 true
425 }
426
427 fn set_precomputed_taint(
438 &self,
439 _cross: Vec<super::taint::TaintPath>,
440 _intra: Vec<super::taint::TaintPath>,
441 ) {
442 }
444
445 fn taint_category(&self) -> Option<super::taint::TaintCategory> {
450 None
451 }
452
453 fn file_extensions(&self) -> &'static [&'static str] {
458 &[]
459 }
460
461 fn content_requirements(&self) -> super::detector_context::ContentFlags {
466 super::detector_context::ContentFlags::empty()
467 }
468
469 fn is_deterministic(&self) -> bool {
478 false
479 }
480
481 fn is_network_bound(&self) -> bool {
485 false
486 }
487
488 fn bypass_postprocessor(&self) -> bool {
491 false
492 }
493}
494
495pub type ProgressCallback = Box<dyn Fn(&str, usize, usize) + Send + Sync>;
497
498#[derive(Debug, Clone, Default)]
500pub struct DetectionSummary {
501 pub detectors_run: usize,
503 pub detectors_succeeded: usize,
505 pub detectors_failed: usize,
507 pub total_findings: usize,
509 pub by_severity: HashMap<Severity, usize>,
511 pub total_duration_ms: u64,
513}
514
515impl DetectionSummary {
516 pub fn add_result(&mut self, result: &DetectorResult) {
518 self.detectors_run += 1;
519 self.total_duration_ms += result.duration_ms;
520
521 if result.success {
522 self.detectors_succeeded += 1;
523 self.total_findings += result.findings.len();
524
525 for finding in &result.findings {
526 *self.by_severity.entry(finding.severity).or_insert(0) += 1;
527 }
528 } else {
529 self.detectors_failed += 1;
530 }
531 }
532}
533
534pub fn compile_glob_patterns(patterns: &[String]) -> Vec<regex::Regex> {
536 patterns
537 .iter()
538 .filter(|p| p.contains('*'))
539 .filter_map(|p| {
540 let re_str = format!("^{}$", p.replace('*', ".*"));
541 regex::Regex::new(&re_str).ok()
542 })
543 .collect()
544}
545
546pub fn should_exclude_path(
548 path: &str,
549 patterns: &[String],
550 compiled_globs: &[regex::Regex],
551) -> bool {
552 for pattern in patterns {
553 if pattern.ends_with('/') {
554 let dir = pattern.trim_end_matches('/');
555 if path.split('/').any(|p| p == dir) {
556 return true;
557 }
558 } else if pattern.contains('*') {
559 continue; } else if path.contains(pattern) {
561 return true;
562 }
563 }
564 let filename = std::path::Path::new(path)
565 .file_name()
566 .and_then(|s| s.to_str())
567 .unwrap_or("");
568 for re in compiled_globs {
569 if re.is_match(path) || re.is_match(filename) {
570 return true;
571 }
572 }
573 false
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn test_detector_config() {
582 let config = DetectorConfig::new()
583 .with_repo_id("test-repo")
584 .with_max_findings(100)
585 .with_option("threshold", serde_json::json!(10));
586
587 assert_eq!(config.repo_id, Some("test-repo".to_string()));
588 assert_eq!(config.max_findings, Some(100));
589 assert_eq!(config.get_option::<i32>("threshold"), Some(10));
590 assert_eq!(config.get_option_or("missing", 5), 5);
591 }
592
593 #[test]
594 fn test_detector_result_success() {
595 let result = DetectorResult::success("TestDetector".to_string(), vec![], 100);
596 assert!(result.success);
597 assert!(result.error.is_none());
598 assert_eq!(result.duration_ms, 100);
599 }
600
601 #[test]
602 fn test_detector_result_failure() {
603 let result = DetectorResult::failure("TestDetector".to_string(), "oops".to_string(), 50);
604 assert!(!result.success);
605 assert_eq!(result.error, Some("oops".to_string()));
606 }
607
608 #[test]
609 fn test_detection_summary() {
610 let mut summary = DetectionSummary::default();
611
612 let result1 = DetectorResult::success("D1".to_string(), vec![], 100);
613 let result2 = DetectorResult::failure("D2".to_string(), "err".to_string(), 50);
614
615 summary.add_result(&result1);
616 summary.add_result(&result2);
617
618 assert_eq!(summary.detectors_run, 2);
619 assert_eq!(summary.detectors_succeeded, 1);
620 assert_eq!(summary.detectors_failed, 1);
621 assert_eq!(summary.total_duration_ms, 150);
622 }
623
624 #[test]
625 fn test_is_test_file() {
626 use super::is_test_file;
627 use std::path::Path;
628
629 assert!(is_test_file(Path::new("foo_test.go")));
630 assert!(is_test_file(Path::new("test_foo.py")));
631 assert!(is_test_file(Path::new("src/tests/helper.py")));
632 assert!(is_test_file(Path::new("app.spec.ts")));
633 assert!(!is_test_file(Path::new("src/main.py")));
634 assert!(!is_test_file(Path::new("testing_utils.py"))); }
636
637 #[test]
638 fn test_requires_graph_annotation_coverage() {
639 let _tmp = tempfile::tempdir().expect("create tempdir");
640 let init = crate::detectors::DetectorInit::test_default();
641 let detectors = crate::detectors::create_all_detectors(&init);
642
643 let graph_independent: Vec<_> = detectors
644 .iter()
645 .filter(|d| !d.requires_graph())
646 .map(|d| d.name())
647 .collect();
648
649 let graph_dependent: Vec<_> = detectors
650 .iter()
651 .filter(|d| d.requires_graph())
652 .map(|d| d.name())
653 .collect();
654
655 println!(
656 "Graph-independent detectors ({}): {:?}",
657 graph_independent.len(),
658 graph_independent
659 );
660 println!(
661 "Graph-dependent detectors ({}): {:?}",
662 graph_dependent.len(),
663 graph_dependent
664 );
665
666 assert!(
668 graph_independent.len() >= 34,
669 "Expected >= 34 graph-independent detectors, got {}",
670 graph_independent.len()
671 );
672 }
673}