1use std::collections::HashMap;
8use std::io::Write;
9use std::path::PathBuf;
10use std::time::Instant;
11
12use anyhow::{bail, Result};
13use clap::Args;
14
15use tldr_core::Language;
16
17use crate::output::{OutputFormat, OutputWriter};
18
19use super::baseline::{get_baseline_content, write_baseline_tmpfile, BaselineStatus};
20use super::changes::detect_changes;
21use super::dead::compose_born_dead_scoped;
22use super::diff::diff_functions;
23use super::l2::types::AnalyzerStatus;
24use super::runner::ToolRunner;
25use super::signature::compose_signature_regression;
26use super::text_format::format_bugbot_text;
27use super::tools::{L1Finding, ToolRegistry, ToolResult};
28use super::types::{
29 BugbotCheckReport, BugbotExitError, BugbotFinding, BugbotSummary, L2AnalyzerResult,
30};
31
32#[derive(Debug, Args)]
34pub struct BugbotCheckArgs {
35 #[arg(default_value = ".")]
37 pub path: PathBuf,
38
39 #[arg(long, default_value = "HEAD")]
41 pub base_ref: String,
42
43 #[arg(long)]
45 pub staged: bool,
46
47 #[arg(long, default_value = "50")]
49 pub max_findings: usize,
50
51 #[arg(long)]
53 pub no_fail: bool,
54
55 #[arg(long, short)]
57 pub quiet: bool,
58
59 #[arg(long, default_value_t = false)]
61 pub no_tools: bool,
62
63 #[arg(long, default_value_t = 60)]
65 pub tool_timeout: u64,
66}
67
68impl BugbotCheckArgs {
69 pub fn run(&self, format: OutputFormat, quiet: bool, lang: Option<Language>) -> Result<()> {
74 let start = Instant::now();
75 let writer = OutputWriter::new(format, quiet);
76 let mut errors: Vec<String> = Vec::new();
77 let mut notes: Vec<String> = Vec::new();
78
79 let language = match lang {
81 Some(l) => l,
82 None => match Language::from_directory(&self.path) {
83 Some(l) => l,
84 None => {
85 bail!("Could not detect language. Use --lang <LANG>");
86 }
87 },
88 };
89
90 let language_str = format!("{:?}", language).to_lowercase();
91 let project = std::fs::canonicalize(&self.path)?;
92
93 let is_first_run = {
95 use super::first_run::{detect_first_run, run_first_run_scan, FirstRunStatus};
96 match detect_first_run(&project) {
97 FirstRunStatus::FirstRun => {
98 let progress_fn = |msg: &str| writer.progress(msg);
99 match run_first_run_scan(&project, &progress_fn) {
100 Ok(result) => {
101 if !result.baseline_errors.is_empty() {
102 for err in &result.baseline_errors {
103 errors.push(format!("first-run baseline: {err}"));
104 }
105 }
106 notes.push(format!(
107 "first_run_baseline_built_in_{}ms",
108 result.elapsed_ms
109 ));
110 true
111 }
112 Err(e) => {
113 errors.push(format!("first-run scan failed: {e}"));
114 true
116 }
117 }
118 }
119 FirstRunStatus::SubsequentRun { .. } => false,
120 }
121 };
122
123 writer.progress(&format!(
124 "Detecting {} changes in {}...",
125 language_str,
126 project.display()
127 ));
128
129 let changes = detect_changes(&project, &self.base_ref, self.staged, &language)?;
131
132 if changes.changed_files.is_empty() {
134 let report = BugbotCheckReport {
135 tool: "bugbot".to_string(),
136 mode: "check".to_string(),
137 language: language_str,
138 base_ref: self.base_ref.clone(),
139 detection_method: changes.detection_method,
140 timestamp: chrono::Utc::now().to_rfc3339(),
141 changed_files: Vec::new(),
142 findings: Vec::new(),
143 summary: build_summary(&[], 0, 0),
144 elapsed_ms: start.elapsed().as_millis() as u64,
145 errors: Vec::new(),
146 notes: vec!["no_changes_detected".to_string()],
147 tool_results: Vec::new(),
148 tools_available: Vec::new(),
149 tools_missing: Vec::new(),
150 l2_engine_results: Vec::new(),
151 };
152
153 if writer.is_text() {
154 writer.write_text(&format_bugbot_text(&report))?;
155 } else {
156 writer.write(&report)?;
157 }
158 return Ok(());
159 }
160
161 writer.progress(&format!(
162 "Found {} changed {} file(s)",
163 changes.changed_files.len(),
164 language_str
165 ));
166
167 let mut all_diffs: HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>> =
169 HashMap::new();
170 let mut _tmpfiles: Vec<tempfile::NamedTempFile> = Vec::new();
172 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
174 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
175
176 for file in &changes.changed_files {
177 match get_baseline_content(&project, file, &self.base_ref) {
178 Ok(BaselineStatus::Exists(content)) => {
179 if file.exists() {
180 let rel_path = file.strip_prefix(&project).unwrap_or(file).to_path_buf();
182 baseline_contents.insert(rel_path.clone(), content.clone());
183 if let Ok(current) = std::fs::read_to_string(file) {
184 current_contents.insert(rel_path, current);
185 }
186
187 match write_baseline_tmpfile(&content, file) {
189 Ok(tmpfile) => {
190 match diff_functions(tmpfile.path(), file) {
191 Ok(report) => {
192 all_diffs.insert(file.clone(), report.changes);
193 }
194 Err(e) => {
195 errors.push(format!(
196 "diff failed for {}: {}",
197 file.display(),
198 e
199 ));
200 }
201 }
202 _tmpfiles.push(tmpfile);
203 }
204 Err(e) => {
205 errors.push(format!(
206 "baseline tmpfile failed for {}: {}",
207 file.display(),
208 e
209 ));
210 }
211 }
212 } else {
213 notes.push(format!("deleted_file:{}", file.display()));
215 }
216 }
217 Ok(BaselineStatus::NewFile) => {
218 if file.exists() {
219 let rel_path = file.strip_prefix(&project).unwrap_or(file).to_path_buf();
221 baseline_contents.insert(rel_path.clone(), String::new());
222 if let Ok(current) = std::fs::read_to_string(file) {
223 current_contents.insert(rel_path, current);
224 }
225
226 let extension = file.extension().and_then(|e| e.to_str()).unwrap_or("txt");
228 match tempfile::Builder::new()
229 .prefix("bugbot_empty_")
230 .suffix(&format!(".{}", extension))
231 .tempfile()
232 {
233 Ok(mut empty_file) => {
234 let _ = empty_file.flush();
236 match diff_functions(empty_file.path(), file) {
237 Ok(report) => {
238 all_diffs.insert(file.clone(), report.changes);
239 }
240 Err(e) => {
241 errors.push(format!(
242 "diff (new file) failed for {}: {}",
243 file.display(),
244 e
245 ));
246 }
247 }
248 _tmpfiles.push(empty_file);
249 }
250 Err(e) => {
251 errors.push(format!(
252 "empty tmpfile failed for {}: {}",
253 file.display(),
254 e
255 ));
256 }
257 }
258 }
259 }
260 Ok(BaselineStatus::GitShowFailed(msg)) => {
261 errors.push(format!("git show failed for {}: {}", file.display(), msg));
262 }
263 Err(e) => {
264 errors.push(format!("baseline error for {}: {}", file.display(), e));
265 }
266 }
267 }
268
269 let files_analyzed = all_diffs.len();
270 let functions_analyzed: usize = all_diffs.values().map(|v| v.len()).sum();
271
272 writer.progress(&format!(
273 "Analyzed {} file(s), {} function-level change(s)",
274 files_analyzed, functions_analyzed
275 ));
276
277 writer.progress("Running L1 + L2 analysis in parallel...");
282 let l2_handle = {
283 use super::l2::{l2_engine_registry, L2Context};
284
285 let engines = l2_engine_registry();
286
287 let relative_changed: Vec<PathBuf> = changes
290 .changed_files
291 .iter()
292 .filter_map(|f| f.strip_prefix(&project).ok().map(|p| p.to_path_buf()))
293 .collect();
294
295 let relative_diffs: HashMap<
297 PathBuf,
298 Vec<crate::commands::remaining::types::ASTChange>,
299 > = all_diffs
300 .iter()
301 .map(|(path, changes)| {
302 let rel = path.strip_prefix(&project).unwrap_or(path).to_path_buf();
303 (rel, changes.clone())
304 })
305 .collect();
306
307 let daemon = super::l2::daemon_client::create_daemon_client(&project);
310
311 let function_diff = build_function_diff(&all_diffs, &project);
313
314 let l2_ctx = L2Context::new(
315 project.clone(),
316 language,
317 relative_changed,
318 function_diff,
319 baseline_contents,
320 current_contents,
321 relative_diffs,
322 )
323 .with_first_run(is_first_run)
324 .with_base_ref(self.base_ref.clone())
325 .with_daemon(daemon);
326
327 std::thread::spawn(move || run_l2_engines(&l2_ctx, &engines))
330 };
331
332 if !self.no_tools {
334 writer.progress("Running L1 diagnostic tools...");
335 }
336 let (l1_raw, tool_results, tools_available, tools_missing) =
337 run_l1_tools_opt(&project, &language_str, self.no_tools, self.tool_timeout);
338
339 let l1_bugbot: Vec<BugbotFinding> = l1_raw.into_iter().map(BugbotFinding::from).collect();
341 let changed_paths: Vec<PathBuf> = changes
342 .changed_files
343 .iter()
344 .filter_map(|f| f.strip_prefix(&project).ok().map(|p| p.to_path_buf()))
345 .collect();
346 let l1_filtered = filter_l1_findings(l1_bugbot, &changed_paths);
347 let l1_count = l1_filtered.len();
348
349 if !tools_available.is_empty() {
350 let ran_count = tool_results.len();
351 let finding_count: usize = tool_results.iter().map(|r| r.finding_count).sum();
352 writer.progress(&format!(
353 "L1 tools: {} ran, {} raw findings, {} after filtering to changed files",
354 ran_count, finding_count, l1_count
355 ));
356 }
357
358 let sig_findings = compose_signature_regression(&all_diffs, &project);
360
361 use crate::commands::remaining::types::{ChangeType, NodeKind};
364 let inserts: Vec<&crate::commands::remaining::types::ASTChange> = all_diffs
365 .values()
366 .flat_map(|changes| changes.iter())
367 .filter(|c| matches!(c.change_type, ChangeType::Insert))
368 .filter(|c| matches!(c.node_kind, NodeKind::Function | NodeKind::Method))
369 .collect();
370 let dead_findings = if !inserts.is_empty() {
371 writer.progress("Scanning for born-dead functions...");
372 match compose_born_dead_scoped(&inserts, &changes.changed_files, &project, &language) {
373 Ok(findings) => findings,
374 Err(e) => {
375 errors.push(format!("born-dead analysis failed: {}", e));
376 Vec::new()
377 }
378 }
379 } else {
380 Vec::new()
381 };
382
383 let (l2_engine_findings, l2_engine_results) = l2_handle.join().unwrap_or_else(|_| {
385 errors.push("L2 engine thread panicked".to_string());
386 (Vec::new(), Vec::new())
387 });
388
389 let compose_l2_count = sig_findings.len() + dead_findings.len();
391 let l2_count = compose_l2_count + l2_engine_findings.len();
392 let mut findings: Vec<BugbotFinding> = Vec::new();
393 findings.extend(l1_filtered);
394 findings.extend(sig_findings);
395 findings.extend(dead_findings);
396 findings.extend(l2_engine_findings);
397
398 use super::l2::dedup::dedup_and_prioritize;
400 findings = dedup_and_prioritize(findings, self.max_findings);
401
402 use super::l2::composition::compose_findings;
404 findings = compose_findings(findings);
405
406 findings.sort_by(|a, b| {
408 severity_rank(&b.severity)
409 .cmp(&severity_rank(&a.severity))
410 .then(a.file.cmp(&b.file))
411 .then(a.line.cmp(&b.line))
412 });
413
414 let summary = build_summary_with_l1(
416 &findings,
417 l1_count,
418 l2_count,
419 files_analyzed,
420 functions_analyzed,
421 &tool_results,
422 );
423 let elapsed_ms = start.elapsed().as_millis() as u64;
424
425 let report = BugbotCheckReport {
427 tool: "bugbot".to_string(),
428 mode: "check".to_string(),
429 language: language_str,
430 base_ref: self.base_ref.clone(),
431 detection_method: changes.detection_method,
432 timestamp: chrono::Utc::now().to_rfc3339(),
433 changed_files: changes.changed_files,
434 findings,
435 summary,
436 elapsed_ms,
437 errors,
438 notes,
439 tool_results,
440 tools_available,
441 tools_missing,
442 l2_engine_results,
443 };
444
445 if writer.is_text() {
447 writer.write_text(&format_bugbot_text(&report))?;
448 } else {
449 writer.write(&report)?;
450 }
451
452 let has_findings = !report.findings.is_empty();
460 let has_errors = !report.errors.is_empty();
461 let has_critical = report.findings.iter().any(|f| f.severity == "critical");
462
463 if has_critical && !self.no_fail {
465 return Err(BugbotExitError::CriticalFindings {
466 count: report
467 .findings
468 .iter()
469 .filter(|f| f.severity == "critical")
470 .count(),
471 }
472 .into());
473 }
474
475 if has_findings && !self.no_fail {
476 return Err(BugbotExitError::FindingsDetected {
477 count: report.findings.len(),
478 }
479 .into());
480 }
481
482 if !has_findings && has_errors && !self.no_fail {
483 return Err(BugbotExitError::AnalysisErrors {
484 count: report.errors.len(),
485 }
486 .into());
487 }
488
489 Ok(())
490 }
491}
492
493fn run_l1_tools_opt(
507 project_root: &std::path::Path,
508 language: &str,
509 no_tools: bool,
510 timeout_secs: u64,
511) -> (Vec<L1Finding>, Vec<ToolResult>, Vec<String>, Vec<String>) {
512 if no_tools {
513 return (Vec::new(), Vec::new(), Vec::new(), Vec::new());
514 }
515
516 let registry = ToolRegistry::new();
517 let (available, missing) = registry.detect_available_tools(language);
518
519 let available_names: Vec<String> = available.iter().map(|t| t.name.to_string()).collect();
520 let missing_names: Vec<String> = missing.iter().map(|t| t.name.to_string()).collect();
521
522 if available.is_empty() {
523 return (Vec::new(), Vec::new(), available_names, missing_names);
524 }
525
526 let runner = ToolRunner::new(timeout_secs);
527 let (tool_results, l1_findings) = runner.run_tools_parallel(&available, project_root);
528
529 (l1_findings, tool_results, available_names, missing_names)
530}
531
532fn severity_rank(severity: &str) -> u8 {
537 match severity {
538 "critical" => 5,
539 "high" => 4,
540 "medium" => 3,
541 "low" => 2,
542 "info" => 1,
543 _ => 0,
544 }
545}
546
547fn build_summary(
549 findings: &[BugbotFinding],
550 files_analyzed: usize,
551 functions_analyzed: usize,
552) -> BugbotSummary {
553 build_summary_with_l1(
554 findings,
555 0,
556 findings.len(),
557 files_analyzed,
558 functions_analyzed,
559 &[],
560 )
561}
562
563fn build_summary_with_l1(
572 findings: &[BugbotFinding],
573 l1_count: usize,
574 l2_count: usize,
575 files_analyzed: usize,
576 functions_analyzed: usize,
577 tool_results: &[super::tools::ToolResult],
578) -> BugbotSummary {
579 let mut by_severity: HashMap<String, usize> = HashMap::new();
580 let mut by_type: HashMap<String, usize> = HashMap::new();
581
582 for f in findings {
583 *by_severity.entry(f.severity.clone()).or_insert(0) += 1;
584 *by_type.entry(f.finding_type.clone()).or_insert(0) += 1;
585 }
586
587 let tools_run = tool_results.len();
588 let tools_failed = tool_results.iter().filter(|r| !r.success).count();
589
590 let actual_l1 = findings
594 .iter()
595 .filter(|f| f.finding_type.starts_with("tool:"))
596 .count();
597 let actual_l2 = findings.len() - actual_l1;
598
599 let final_l1 = if actual_l1 + actual_l2 != l1_count + l2_count {
601 actual_l1
602 } else {
603 l1_count
604 };
605 let final_l2 = if actual_l1 + actual_l2 != l1_count + l2_count {
606 actual_l2
607 } else {
608 l2_count
609 };
610
611 BugbotSummary {
612 total_findings: findings.len(),
613 by_severity,
614 by_type,
615 files_analyzed,
616 functions_analyzed,
617 l1_findings: final_l1,
618 l2_findings: final_l2,
619 tools_run,
620 tools_failed,
621 }
622}
623
624fn filter_l1_findings(
630 findings: Vec<BugbotFinding>,
631 changed_files: &[PathBuf],
632) -> Vec<BugbotFinding> {
633 if changed_files.is_empty() {
634 return findings;
635 }
636 findings
637 .into_iter()
638 .filter(|f| {
639 changed_files.iter().any(|cf| {
640 cf == &f.file
642 || f.file.ends_with(cf)
644 || cf.ends_with(&f.file)
645 })
646 })
647 .collect()
648}
649
650fn run_single_engine(
655 engine: &dyn super::l2::L2Engine,
656 ctx: &super::l2::L2Context,
657) -> (Vec<BugbotFinding>, L2AnalyzerResult) {
658 let supported = engine.languages();
661 if !supported.is_empty() && !supported.contains(&ctx.language) {
662 return (
663 Vec::new(),
664 L2AnalyzerResult {
665 name: engine.name().to_string(),
666 success: true,
667 duration_ms: 0,
668 finding_count: 0,
669 functions_analyzed: 0,
670 functions_skipped: 0,
671 status: format!(
672 "Skipped: {} does not support {:?}",
673 engine.name(),
674 ctx.language
675 ),
676 errors: vec![],
677 },
678 );
679 }
680
681 let start = Instant::now();
682 let output = engine.analyze(ctx);
683 let duration = start.elapsed().as_millis() as u64;
684
685 let status_str = match &output.status {
686 AnalyzerStatus::Complete => "complete".to_string(),
687 AnalyzerStatus::Partial { reason } => format!("partial ({})", reason),
688 AnalyzerStatus::Skipped { reason } => format!("skipped ({})", reason),
689 AnalyzerStatus::TimedOut { partial_findings } => {
690 format!("timed out ({} partial findings)", partial_findings)
691 }
692 };
693
694 let errors = match &output.status {
695 AnalyzerStatus::Partial { reason } => vec![reason.clone()],
696 AnalyzerStatus::TimedOut { .. } => vec!["Engine timed out".to_string()],
697 _ => vec![],
698 };
699
700 let result = L2AnalyzerResult {
701 name: engine.name().to_string(),
702 success: matches!(output.status, AnalyzerStatus::Complete),
703 duration_ms: duration,
704 finding_count: output.findings.len(),
705 functions_analyzed: output.functions_analyzed,
706 functions_skipped: output.functions_skipped,
707 status: status_str,
708 errors,
709 };
710
711 (output.findings, result)
712}
713
714fn run_l2_engines(
719 ctx: &super::l2::L2Context,
720 engines: &[Box<dyn super::l2::L2Engine>],
721) -> (Vec<BugbotFinding>, Vec<L2AnalyzerResult>) {
722 let mut all_findings = Vec::new();
723 let mut results = Vec::new();
724
725 for engine in engines {
726 let (findings, result) = run_single_engine(engine.as_ref(), ctx);
727 all_findings.extend(findings);
728 results.push(result);
729 }
730
731 (all_findings, results)
732}
733
734fn build_function_diff(
745 all_diffs: &HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>>,
746 project: &std::path::Path,
747) -> super::l2::context::FunctionDiff {
748 use super::l2::context::{DeletedFunction, FunctionChange, FunctionDiff, InsertedFunction};
749 use super::l2::types::FunctionId;
750 use crate::commands::remaining::types::{ChangeType, NodeKind};
751
752 let mut changed_fns = Vec::new();
753 let mut inserted_fns = Vec::new();
754 let mut deleted_fns = Vec::new();
755
756 for (abs_path, changes) in all_diffs {
757 let rel_path = abs_path
758 .strip_prefix(project)
759 .unwrap_or(abs_path)
760 .to_path_buf();
761
762 for change in changes {
763 if !matches!(change.node_kind, NodeKind::Function | NodeKind::Method) {
765 continue;
766 }
767
768 let name = match &change.name {
769 Some(n) => n.clone(),
770 None => continue, };
772
773 let def_line = change
774 .new_location
775 .as_ref()
776 .or(change.old_location.as_ref())
777 .map(|loc| loc.line as usize)
778 .unwrap_or(0);
779
780 let func_id = FunctionId::new(rel_path.clone(), &name, def_line);
781
782 match change.change_type {
783 ChangeType::Update => {
784 let old_source = change.old_text.clone().unwrap_or_default();
785 let new_source = change.new_text.clone().unwrap_or_default();
786 changed_fns.push(FunctionChange {
787 id: func_id,
788 name: name.clone(),
789 old_source,
790 new_source,
791 });
792 }
793 ChangeType::Insert => {
794 let source = change.new_text.clone().unwrap_or_default();
795 inserted_fns.push(InsertedFunction {
796 id: func_id,
797 name: name.clone(),
798 source,
799 });
800 }
801 ChangeType::Delete => {
802 deleted_fns.push(DeletedFunction {
803 id: func_id,
804 name: name.clone(),
805 });
806 }
807 ChangeType::Move
808 | ChangeType::Rename
809 | ChangeType::Extract
810 | ChangeType::Inline
811 | ChangeType::Format => {
812 if change.old_text.is_some() && change.new_text.is_some() {
814 changed_fns.push(FunctionChange {
815 id: func_id,
816 name: name.clone(),
817 old_source: change.old_text.clone().unwrap_or_default(),
818 new_source: change.new_text.clone().unwrap_or_default(),
819 });
820 }
821 }
822 }
823 }
824 }
825
826 FunctionDiff {
827 changed: changed_fns,
828 inserted: inserted_fns,
829 deleted: deleted_fns,
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
842 fn test_severity_rank_ordering() {
843 assert_eq!(severity_rank("critical"), 5);
844 assert_eq!(severity_rank("high"), 4);
845 assert_eq!(severity_rank("medium"), 3);
846 assert_eq!(severity_rank("low"), 2);
847 assert_eq!(severity_rank("info"), 1); assert_eq!(severity_rank(""), 0);
849 }
850
851 #[test]
853 fn test_severity_rank_critical() {
854 assert_eq!(severity_rank("critical"), 5);
855 assert!(
856 severity_rank("critical") > severity_rank("high"),
857 "critical ({}) should rank above high ({})",
858 severity_rank("critical"),
859 severity_rank("high"),
860 );
861 }
862
863 #[test]
864 fn test_build_summary_empty() {
865 let summary = build_summary(&[], 0, 0);
866 assert_eq!(summary.total_findings, 0);
867 assert!(summary.by_severity.is_empty());
868 assert!(summary.by_type.is_empty());
869 assert_eq!(summary.files_analyzed, 0);
870 assert_eq!(summary.functions_analyzed, 0);
871 }
872
873 #[test]
874 fn test_build_summary_counts() {
875 let findings = vec![
876 BugbotFinding {
877 finding_type: "signature-regression".to_string(),
878 severity: "high".to_string(),
879 file: PathBuf::from("a.rs"),
880 function: "foo".to_string(),
881 line: 10,
882 message: "param removed".to_string(),
883 evidence: serde_json::Value::Null,
884 confidence: None,
885 finding_id: None,
886 },
887 BugbotFinding {
888 finding_type: "born-dead".to_string(),
889 severity: "low".to_string(),
890 file: PathBuf::from("b.rs"),
891 function: "bar".to_string(),
892 line: 20,
893 message: "no callers".to_string(),
894 evidence: serde_json::Value::Null,
895 confidence: None,
896 finding_id: None,
897 },
898 BugbotFinding {
899 finding_type: "signature-regression".to_string(),
900 severity: "high".to_string(),
901 file: PathBuf::from("c.rs"),
902 function: "baz".to_string(),
903 line: 5,
904 message: "return type changed".to_string(),
905 evidence: serde_json::Value::Null,
906 confidence: None,
907 finding_id: None,
908 },
909 ];
910
911 let summary = build_summary(&findings, 3, 10);
912 assert_eq!(summary.total_findings, 3);
913 assert_eq!(summary.by_severity.get("high"), Some(&2));
914 assert_eq!(summary.by_severity.get("low"), Some(&1));
915 assert_eq!(summary.by_type.get("signature-regression"), Some(&2));
916 assert_eq!(summary.by_type.get("born-dead"), Some(&1));
917 assert_eq!(summary.files_analyzed, 3);
918 assert_eq!(summary.functions_analyzed, 10);
919 }
920
921 #[test]
922 fn test_findings_sort_severity_first() {
923 let mut findings = [
924 BugbotFinding {
925 finding_type: "born-dead".to_string(),
926 severity: "low".to_string(),
927 file: PathBuf::from("a.rs"),
928 function: "f1".to_string(),
929 line: 1,
930 message: String::new(),
931 evidence: serde_json::Value::Null,
932 confidence: None,
933 finding_id: None,
934 },
935 BugbotFinding {
936 finding_type: "signature-regression".to_string(),
937 severity: "high".to_string(),
938 file: PathBuf::from("z.rs"),
939 function: "f2".to_string(),
940 line: 100,
941 message: String::new(),
942 evidence: serde_json::Value::Null,
943 confidence: None,
944 finding_id: None,
945 },
946 BugbotFinding {
947 finding_type: "born-dead".to_string(),
948 severity: "medium".to_string(),
949 file: PathBuf::from("b.rs"),
950 function: "f3".to_string(),
951 line: 50,
952 message: String::new(),
953 evidence: serde_json::Value::Null,
954 confidence: None,
955 finding_id: None,
956 },
957 ];
958
959 findings.sort_by(|a, b| {
960 severity_rank(&b.severity)
961 .cmp(&severity_rank(&a.severity))
962 .then(a.file.cmp(&b.file))
963 .then(a.line.cmp(&b.line))
964 });
965
966 assert_eq!(findings[0].severity, "high");
967 assert_eq!(findings[1].severity, "medium");
968 assert_eq!(findings[2].severity, "low");
969 }
970
971 #[test]
972 fn test_findings_sort_file_then_line_within_same_severity() {
973 let mut findings = [
974 BugbotFinding {
975 finding_type: "sig".to_string(),
976 severity: "high".to_string(),
977 file: PathBuf::from("z.rs"),
978 function: "f1".to_string(),
979 line: 10,
980 message: String::new(),
981 evidence: serde_json::Value::Null,
982 confidence: None,
983 finding_id: None,
984 },
985 BugbotFinding {
986 finding_type: "sig".to_string(),
987 severity: "high".to_string(),
988 file: PathBuf::from("a.rs"),
989 function: "f2".to_string(),
990 line: 50,
991 message: String::new(),
992 evidence: serde_json::Value::Null,
993 confidence: None,
994 finding_id: None,
995 },
996 BugbotFinding {
997 finding_type: "sig".to_string(),
998 severity: "high".to_string(),
999 file: PathBuf::from("a.rs"),
1000 function: "f3".to_string(),
1001 line: 5,
1002 message: String::new(),
1003 evidence: serde_json::Value::Null,
1004 confidence: None,
1005 finding_id: None,
1006 },
1007 ];
1008
1009 findings.sort_by(|a, b| {
1010 severity_rank(&b.severity)
1011 .cmp(&severity_rank(&a.severity))
1012 .then(a.file.cmp(&b.file))
1013 .then(a.line.cmp(&b.line))
1014 });
1015
1016 assert_eq!(findings[0].file, PathBuf::from("a.rs"));
1018 assert_eq!(findings[0].line, 5);
1019 assert_eq!(findings[1].file, PathBuf::from("a.rs"));
1020 assert_eq!(findings[1].line, 50);
1021 assert_eq!(findings[2].file, PathBuf::from("z.rs"));
1022 }
1023
1024 #[test]
1025 fn test_findings_truncation() {
1026 let max_findings = 2;
1027 let mut findings: Vec<BugbotFinding> = (0..5)
1028 .map(|i| BugbotFinding {
1029 finding_type: "test".to_string(),
1030 severity: "low".to_string(),
1031 file: PathBuf::from(format!("f{}.rs", i)),
1032 function: format!("fn_{}", i),
1033 line: i,
1034 message: String::new(),
1035 evidence: serde_json::Value::Null,
1036 confidence: None,
1037 finding_id: None,
1038 })
1039 .collect();
1040
1041 let mut notes: Vec<String> = Vec::new();
1042 if findings.len() > max_findings {
1043 notes.push(format!("truncated_to_{}", max_findings));
1044 findings.truncate(max_findings);
1045 }
1046
1047 assert_eq!(findings.len(), 2);
1048 assert_eq!(notes, vec!["truncated_to_2"]);
1049 }
1050
1051 fn init_git_repo() -> tempfile::TempDir {
1057 let tmp = tempfile::TempDir::new().expect("create temp dir");
1058 let dir = tmp.path();
1059
1060 std::process::Command::new("git")
1061 .args(["init"])
1062 .current_dir(dir)
1063 .output()
1064 .expect("git init");
1065
1066 std::process::Command::new("git")
1067 .args(["config", "user.email", "test@test.com"])
1068 .current_dir(dir)
1069 .output()
1070 .expect("git config email");
1071
1072 std::process::Command::new("git")
1073 .args(["config", "user.name", "Test"])
1074 .current_dir(dir)
1075 .output()
1076 .expect("git config name");
1077
1078 std::fs::write(dir.join("lib.rs"), "fn placeholder() {}\n").expect("write lib.rs");
1080 std::process::Command::new("git")
1081 .args(["add", "."])
1082 .current_dir(dir)
1083 .output()
1084 .expect("git add");
1085 std::process::Command::new("git")
1086 .args(["commit", "-m", "init"])
1087 .current_dir(dir)
1088 .output()
1089 .expect("git commit");
1090
1091 tmp
1092 }
1093
1094 #[test]
1095 fn test_run_no_changes_produces_empty_report() {
1096 let tmp = init_git_repo();
1097 let args = BugbotCheckArgs {
1098 path: tmp.path().to_path_buf(),
1099 base_ref: "HEAD".to_string(),
1100 staged: false,
1101 max_findings: 50,
1102 no_fail: false,
1103 quiet: true,
1104 no_tools: false,
1105 tool_timeout: 60,
1106 };
1107
1108 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1110 assert!(result.is_ok(), "run() should succeed: {:?}", result.err());
1111 }
1112
1113 #[test]
1114 fn test_run_with_signature_change_finds_regression() {
1115 let tmp = init_git_repo();
1116 let dir = tmp.path();
1117
1118 let original = "pub fn compute(x: i32, y: i32) -> i32 {\n x + y\n}\n";
1120 std::fs::write(dir.join("lib.rs"), original).expect("write lib.rs");
1121 std::process::Command::new("git")
1122 .args(["add", "lib.rs"])
1123 .current_dir(dir)
1124 .output()
1125 .expect("git add");
1126 std::process::Command::new("git")
1127 .args(["commit", "-m", "add compute"])
1128 .current_dir(dir)
1129 .output()
1130 .expect("git commit");
1131
1132 let modified = "pub fn compute(x: i32) -> i32 {\n x * 2\n}\n";
1134 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1135
1136 let args = BugbotCheckArgs {
1137 path: dir.to_path_buf(),
1138 base_ref: "HEAD".to_string(),
1139 staged: false,
1140 max_findings: 50,
1141 no_fail: true,
1142 quiet: true,
1143 no_tools: false,
1144 tool_timeout: 60,
1145 };
1146
1147 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1150 assert!(result.is_ok(), "run() should succeed: {:?}", result.err());
1151 }
1152
1153 #[test]
1154 fn test_run_new_file_produces_insert_changes() {
1155 let tmp = init_git_repo();
1156 let dir = tmp.path();
1157
1158 let new_code = "fn brand_new_function() {\n println!(\"hello\");\n}\n";
1160 std::fs::write(dir.join("new_module.rs"), new_code).expect("write new_module.rs");
1161
1162 let args = BugbotCheckArgs {
1163 path: dir.to_path_buf(),
1164 base_ref: "HEAD".to_string(),
1165 staged: false,
1166 max_findings: 50,
1167 no_fail: true,
1168 quiet: true,
1169 no_tools: false,
1170 tool_timeout: 60,
1171 };
1172
1173 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1176 assert!(
1177 result.is_ok(),
1178 "run() should succeed with no_fail: {:?}",
1179 result.err()
1180 );
1181 }
1182
1183 #[test]
1184 fn test_run_elapsed_ms_is_populated() {
1185 let start = Instant::now();
1187 std::thread::sleep(std::time::Duration::from_millis(1));
1188 let elapsed_ms = start.elapsed().as_millis() as u64;
1189 assert!(elapsed_ms >= 1, "Instant timing should work");
1190 }
1191
1192 #[test]
1197 fn test_run_findings_without_no_fail_returns_error() {
1198 let tmp = init_git_repo();
1201 let dir = tmp.path();
1202
1203 let original = "pub fn compute(x: i32, y: i32) -> i32 {\n x + y\n}\n";
1204 std::fs::write(dir.join("lib.rs"), original).expect("write lib.rs");
1205 std::process::Command::new("git")
1206 .args(["add", "lib.rs"])
1207 .current_dir(dir)
1208 .output()
1209 .expect("git add");
1210 std::process::Command::new("git")
1211 .args(["commit", "-m", "add compute"])
1212 .current_dir(dir)
1213 .output()
1214 .expect("git commit");
1215
1216 let modified = "pub fn compute(x: i32) -> i32 {\n x * 2\n}\n";
1217 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1218
1219 let args = BugbotCheckArgs {
1220 path: dir.to_path_buf(),
1221 base_ref: "HEAD".to_string(),
1222 staged: false,
1223 max_findings: 50,
1224 no_fail: false,
1225 quiet: true,
1226 no_tools: false,
1227 tool_timeout: 60,
1228 };
1229
1230 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1231 assert!(
1232 result.is_err(),
1233 "run() should return Err when findings exist"
1234 );
1235
1236 let err = result.unwrap_err();
1238 use crate::commands::bugbot::BugbotExitError;
1239 let bugbot_err = err
1240 .downcast_ref::<BugbotExitError>()
1241 .expect("error should be BugbotExitError");
1242 assert_eq!(
1243 bugbot_err.exit_code(),
1244 1,
1245 "exit code should be 1 for findings"
1246 );
1247 }
1248
1249 #[test]
1254 fn test_max_findings_zero_means_unlimited() {
1255 let max_findings: usize = 0;
1257 let mut findings: Vec<BugbotFinding> = (0..5)
1258 .map(|i| BugbotFinding {
1259 finding_type: "test".to_string(),
1260 severity: "low".to_string(),
1261 file: PathBuf::from(format!("f{}.rs", i)),
1262 function: format!("fn_{}", i),
1263 line: i,
1264 message: String::new(),
1265 evidence: serde_json::Value::Null,
1266 confidence: None,
1267 finding_id: None,
1268 })
1269 .collect();
1270
1271 let mut notes: Vec<String> = Vec::new();
1272 if max_findings > 0 && findings.len() > max_findings {
1273 notes.push(format!("truncated_to_{}", max_findings));
1274 findings.truncate(max_findings);
1275 }
1276
1277 assert_eq!(findings.len(), 5, "max_findings=0 should not truncate");
1278 assert!(notes.is_empty(), "no truncation note with max_findings=0");
1279 }
1280
1281 #[test]
1286 fn test_severity_rank_info_below_low() {
1287 assert!(
1289 severity_rank("info") < severity_rank("low"),
1290 "info ({}) should rank below low ({})",
1291 severity_rank("info"),
1292 severity_rank("low")
1293 );
1294 assert!(
1295 severity_rank("info") > 0,
1296 "PM-8: info should have an explicit rank > 0, not wildcard"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_filter_l1_findings_to_changed_files() {
1302 let l1_findings: Vec<BugbotFinding> = vec![
1304 BugbotFinding {
1305 finding_type: "tool:clippy".to_string(),
1306 severity: "medium".to_string(),
1307 file: PathBuf::from("src/main.rs"),
1308 function: String::new(),
1309 line: 10,
1310 message: "warning in changed file".to_string(),
1311 evidence: serde_json::Value::Null,
1312 confidence: None,
1313 finding_id: None,
1314 },
1315 BugbotFinding {
1316 finding_type: "tool:clippy".to_string(),
1317 severity: "low".to_string(),
1318 file: PathBuf::from("src/untouched.rs"),
1319 function: String::new(),
1320 line: 5,
1321 message: "warning in untouched file".to_string(),
1322 evidence: serde_json::Value::Null,
1323 confidence: None,
1324 finding_id: None,
1325 },
1326 BugbotFinding {
1327 finding_type: "tool:clippy".to_string(),
1328 severity: "high".to_string(),
1329 file: PathBuf::from("src/lib.rs"),
1330 function: String::new(),
1331 line: 20,
1332 message: "error in changed file".to_string(),
1333 evidence: serde_json::Value::Null,
1334 confidence: None,
1335 finding_id: None,
1336 },
1337 ];
1338
1339 let changed_files: Vec<PathBuf> =
1340 vec![PathBuf::from("src/main.rs"), PathBuf::from("src/lib.rs")];
1341
1342 let filtered = filter_l1_findings(l1_findings, &changed_files);
1343
1344 assert_eq!(
1345 filtered.len(),
1346 2,
1347 "should keep only 2 findings matching changed files"
1348 );
1349 let untouched = std::path::Path::new("src/untouched.rs");
1350 assert!(
1351 filtered.iter().all(|f| f.file != untouched),
1352 "PM-3: untouched file findings should be excluded"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_filter_l1_findings_empty_changed_files_keeps_all() {
1358 let l1_findings: Vec<BugbotFinding> = vec![
1360 BugbotFinding {
1361 finding_type: "tool:clippy".to_string(),
1362 severity: "medium".to_string(),
1363 file: PathBuf::from("src/main.rs"),
1364 function: String::new(),
1365 line: 10,
1366 message: "warning".to_string(),
1367 evidence: serde_json::Value::Null,
1368 confidence: None,
1369 finding_id: None,
1370 },
1371 BugbotFinding {
1372 finding_type: "tool:clippy".to_string(),
1373 severity: "low".to_string(),
1374 file: PathBuf::from("src/other.rs"),
1375 function: String::new(),
1376 line: 5,
1377 message: "another warning".to_string(),
1378 evidence: serde_json::Value::Null,
1379 confidence: None,
1380 finding_id: None,
1381 },
1382 ];
1383
1384 let changed_files: Vec<PathBuf> = vec![];
1385
1386 let filtered = filter_l1_findings(l1_findings, &changed_files);
1387
1388 assert_eq!(
1389 filtered.len(),
1390 2,
1391 "empty changed_files should keep all findings"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_build_summary_with_l1_and_l2() {
1397 let l2_findings = vec![BugbotFinding {
1399 finding_type: "signature-regression".to_string(),
1400 severity: "high".to_string(),
1401 file: PathBuf::from("a.rs"),
1402 function: "foo".to_string(),
1403 line: 10,
1404 message: "param removed".to_string(),
1405 evidence: serde_json::Value::Null,
1406 confidence: None,
1407 finding_id: None,
1408 }];
1409 let l1_findings = vec![
1410 BugbotFinding {
1411 finding_type: "tool:clippy".to_string(),
1412 severity: "medium".to_string(),
1413 file: PathBuf::from("b.rs"),
1414 function: String::new(),
1415 line: 5,
1416 message: "unused var".to_string(),
1417 evidence: serde_json::Value::Null,
1418 confidence: None,
1419 finding_id: None,
1420 },
1421 BugbotFinding {
1422 finding_type: "tool:cargo-audit".to_string(),
1423 severity: "high".to_string(),
1424 file: PathBuf::from("Cargo.lock"),
1425 function: String::new(),
1426 line: 1,
1427 message: "vuln".to_string(),
1428 evidence: serde_json::Value::Null,
1429 confidence: None,
1430 finding_id: None,
1431 },
1432 ];
1433
1434 let tool_results = vec![
1435 super::super::tools::ToolResult {
1436 name: "clippy".to_string(),
1437 category: super::super::tools::ToolCategory::Linter,
1438 success: true,
1439 duration_ms: 100,
1440 finding_count: 1,
1441 error: None,
1442 exit_code: Some(0),
1443 },
1444 super::super::tools::ToolResult {
1445 name: "cargo-audit".to_string(),
1446 category: super::super::tools::ToolCategory::SecurityScanner,
1447 success: true,
1448 duration_ms: 50,
1449 finding_count: 1,
1450 error: None,
1451 exit_code: Some(0),
1452 },
1453 ];
1454
1455 let mut all_findings = Vec::new();
1456 all_findings.extend(l1_findings.clone());
1457 all_findings.extend(l2_findings.clone());
1458
1459 let summary = build_summary_with_l1(
1460 &all_findings,
1461 l1_findings.len(),
1462 l2_findings.len(),
1463 5,
1464 20,
1465 &tool_results,
1466 );
1467
1468 assert_eq!(summary.total_findings, 3);
1469 assert_eq!(summary.l1_findings, 2);
1470 assert_eq!(summary.l2_findings, 1);
1471 assert_eq!(summary.tools_run, 2);
1472 assert_eq!(summary.tools_failed, 0);
1473 assert_eq!(summary.files_analyzed, 5);
1474 assert_eq!(summary.functions_analyzed, 20);
1475 }
1476
1477 #[test]
1478 fn test_build_summary_with_l1_counts_failed_tools() {
1479 let findings: Vec<BugbotFinding> = vec![];
1480 let tool_results = vec![
1481 super::super::tools::ToolResult {
1482 name: "clippy".to_string(),
1483 category: super::super::tools::ToolCategory::Linter,
1484 success: true,
1485 duration_ms: 100,
1486 finding_count: 0,
1487 error: None,
1488 exit_code: Some(0),
1489 },
1490 super::super::tools::ToolResult {
1491 name: "cargo-audit".to_string(),
1492 category: super::super::tools::ToolCategory::SecurityScanner,
1493 success: false,
1494 duration_ms: 50,
1495 finding_count: 0,
1496 error: Some("binary not found".to_string()),
1497 exit_code: None,
1498 },
1499 ];
1500
1501 let summary = build_summary_with_l1(&findings, 0, 0, 3, 10, &tool_results);
1502
1503 assert_eq!(summary.tools_run, 2);
1504 assert_eq!(summary.tools_failed, 1);
1505 }
1506
1507 #[test]
1508 fn test_no_tools_available_graceful_degradation() {
1509 let l2_findings = vec![BugbotFinding {
1513 finding_type: "born-dead".to_string(),
1514 severity: "low".to_string(),
1515 file: PathBuf::from("src/lib.rs"),
1516 function: "dead_fn".to_string(),
1517 line: 10,
1518 message: "no callers".to_string(),
1519 evidence: serde_json::Value::Null,
1520 confidence: None,
1521 finding_id: None,
1522 }];
1523
1524 let summary = build_summary_with_l1(&l2_findings, 0, 1, 1, 5, &[]);
1525
1526 assert_eq!(summary.total_findings, 1);
1527 assert_eq!(summary.l1_findings, 0);
1528 assert_eq!(summary.l2_findings, 1);
1529 assert_eq!(summary.tools_run, 0);
1530 assert_eq!(summary.tools_failed, 0);
1531 }
1532
1533 #[test]
1538 fn test_no_tools_flag_defaults_to_false() {
1539 let args = BugbotCheckArgs {
1540 path: PathBuf::from("."),
1541 base_ref: "HEAD".to_string(),
1542 staged: false,
1543 max_findings: 50,
1544 no_fail: false,
1545 quiet: true,
1546 no_tools: false,
1547 tool_timeout: 60,
1548 };
1549 assert!(!args.no_tools);
1550 }
1551
1552 #[test]
1553 fn test_tool_timeout_default_is_60() {
1554 let args = BugbotCheckArgs {
1555 path: PathBuf::from("."),
1556 base_ref: "HEAD".to_string(),
1557 staged: false,
1558 max_findings: 50,
1559 no_fail: false,
1560 quiet: true,
1561 no_tools: false,
1562 tool_timeout: 60,
1563 };
1564 assert_eq!(args.tool_timeout, 60);
1565 }
1566
1567 #[test]
1568 fn test_no_tools_skips_l1_analysis() {
1569 let tmp = init_git_repo();
1572 let dir = tmp.path();
1573
1574 let code = "pub fn hello() { println!(\"hello\"); }\n";
1576 std::fs::write(dir.join("lib.rs"), code).expect("write lib.rs");
1577 std::process::Command::new("git")
1578 .args(["add", "lib.rs"])
1579 .current_dir(dir)
1580 .output()
1581 .expect("git add");
1582 std::process::Command::new("git")
1583 .args(["commit", "-m", "add hello"])
1584 .current_dir(dir)
1585 .output()
1586 .expect("git commit");
1587
1588 let modified = "pub fn hello(name: &str) { println!(\"hello {}\", name); }\n";
1590 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1591
1592 let args = BugbotCheckArgs {
1593 path: dir.to_path_buf(),
1594 base_ref: "HEAD".to_string(),
1595 staged: false,
1596 max_findings: 50,
1597 no_fail: true,
1598 quiet: true,
1599 no_tools: true,
1600 tool_timeout: 60,
1601 };
1602
1603 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1605 assert!(
1607 result.is_ok(),
1608 "run() should succeed with --no-tools: {:?}",
1609 result.err()
1610 );
1611 }
1612
1613 #[test]
1614 fn test_no_tools_report_has_no_l1_data() {
1615 let (l1_findings, tool_results, available, missing) =
1619 run_l1_tools_opt(std::path::Path::new("/nonexistent"), "rust", true, 60);
1620
1621 assert!(
1622 l1_findings.is_empty(),
1623 "no_tools should produce empty L1 findings"
1624 );
1625 assert!(
1626 tool_results.is_empty(),
1627 "no_tools should produce empty tool_results"
1628 );
1629 assert!(
1630 available.is_empty(),
1631 "no_tools should produce empty tools_available"
1632 );
1633 assert!(
1634 missing.is_empty(),
1635 "no_tools should produce empty tools_missing"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_tool_timeout_passed_to_runner() {
1641 let (_l1, _results, _avail, _missing) =
1647 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 5);
1648 }
1650
1651 #[test]
1652 fn test_no_tools_no_changes_report() {
1653 let tmp = init_git_repo();
1655 let args = BugbotCheckArgs {
1656 path: tmp.path().to_path_buf(),
1657 base_ref: "HEAD".to_string(),
1658 staged: false,
1659 max_findings: 50,
1660 no_fail: false,
1661 quiet: true,
1662 no_tools: true,
1663 tool_timeout: 60,
1664 };
1665
1666 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1667 assert!(
1668 result.is_ok(),
1669 "no-tools + no-changes should succeed: {:?}",
1670 result.err()
1671 );
1672 }
1673
1674 #[test]
1675 fn test_default_behavior_without_flags_runs_l1() {
1676 let (_l1, _results, available, _missing) =
1679 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 60);
1680 let _ = available; }
1686
1687 #[test]
1692 fn test_build_summary_l1_l2_counts_reflect_actual_findings() {
1693 let mut all_findings: Vec<BugbotFinding> = Vec::new();
1700
1701 for i in 0..5 {
1703 all_findings.push(BugbotFinding {
1704 finding_type: "tool:clippy".to_string(),
1705 severity: "medium".to_string(),
1706 file: PathBuf::from(format!("l1_{}.rs", i)),
1707 function: String::new(),
1708 line: i,
1709 message: "lint".to_string(),
1710 evidence: serde_json::Value::Null,
1711 confidence: None,
1712 finding_id: None,
1713 });
1714 }
1715
1716 for i in 0..3 {
1718 all_findings.push(BugbotFinding {
1719 finding_type: "signature-regression".to_string(),
1720 severity: "high".to_string(),
1721 file: PathBuf::from(format!("l2_{}.rs", i)),
1722 function: format!("fn_{}", i),
1723 line: i + 100,
1724 message: "param removed".to_string(),
1725 evidence: serde_json::Value::Null,
1726 confidence: None,
1727 finding_id: None,
1728 });
1729 }
1730
1731 all_findings.truncate(4);
1733
1734 let actual_l1 = all_findings
1736 .iter()
1737 .filter(|f| f.finding_type.starts_with("tool:"))
1738 .count();
1739 let actual_l2 = all_findings
1740 .iter()
1741 .filter(|f| !f.finding_type.starts_with("tool:"))
1742 .count();
1743
1744 let summary = build_summary_with_l1(&all_findings, actual_l1, actual_l2, 3, 10, &[]);
1745
1746 assert_eq!(
1748 summary.total_findings,
1749 summary.l1_findings + summary.l2_findings,
1750 "total_findings ({}) should equal l1_findings ({}) + l2_findings ({})",
1751 summary.total_findings,
1752 summary.l1_findings,
1753 summary.l2_findings
1754 );
1755
1756 assert_eq!(summary.total_findings, 4, "should reflect truncated count");
1758 assert_eq!(
1759 summary.l1_findings, actual_l1,
1760 "l1 should reflect post-truncation count"
1761 );
1762 assert_eq!(
1763 summary.l2_findings, actual_l2,
1764 "l2 should reflect post-truncation count"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_summary_counts_consistent_after_heavy_truncation() {
1770 let mut all_findings: Vec<BugbotFinding> = Vec::new();
1772
1773 for i in 0..10 {
1775 all_findings.push(BugbotFinding {
1776 finding_type: "tool:clippy".to_string(),
1777 severity: "medium".to_string(),
1778 file: PathBuf::from(format!("l1_{}.rs", i)),
1779 function: String::new(),
1780 line: i,
1781 message: "lint".to_string(),
1782 evidence: serde_json::Value::Null,
1783 confidence: None,
1784 finding_id: None,
1785 });
1786 }
1787
1788 for i in 0..10 {
1790 all_findings.push(BugbotFinding {
1791 finding_type: "born-dead".to_string(),
1792 severity: "low".to_string(),
1793 file: PathBuf::from(format!("l2_{}.rs", i)),
1794 function: format!("fn_{}", i),
1795 line: i + 100,
1796 message: "no callers".to_string(),
1797 evidence: serde_json::Value::Null,
1798 confidence: None,
1799 finding_id: None,
1800 });
1801 }
1802
1803 let pre_l1 = 10;
1805 let pre_l2 = 10;
1806 assert_eq!(all_findings.len(), 20);
1807
1808 all_findings.truncate(1);
1810
1811 let _post_l1 = all_findings
1813 .iter()
1814 .filter(|f| f.finding_type.starts_with("tool:"))
1815 .count();
1816 let _post_l2 = all_findings
1817 .iter()
1818 .filter(|f| !f.finding_type.starts_with("tool:"))
1819 .count();
1820
1821 let bad_summary = build_summary_with_l1(&all_findings, pre_l1, pre_l2, 3, 10, &[]);
1823
1824 assert_eq!(
1827 bad_summary.total_findings,
1828 bad_summary.l1_findings + bad_summary.l2_findings,
1829 "F14: total ({}) must equal l1 ({}) + l2 ({}) even with stale pre-truncation counts",
1830 bad_summary.total_findings,
1831 bad_summary.l1_findings,
1832 bad_summary.l2_findings,
1833 );
1834 }
1835
1836 #[test]
1841 fn test_critical_exit_code_3() {
1842 let tmp = init_git_repo();
1845 let dir = tmp.path();
1846
1847 let code = r#"
1849fn main() {
1850 // AWS secret key hardcoded (intentional test fixture)
1851 let _key = "AKIAIOSFODNN7EXAMPLE";
1852 let _secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
1853}
1854"#;
1855 std::fs::write(dir.join("lib.rs"), "fn placeholder() {}\n").ok();
1856 std::process::Command::new("git")
1857 .args(["add", "lib.rs"])
1858 .current_dir(dir)
1859 .output()
1860 .expect("git add");
1861 std::process::Command::new("git")
1862 .args(["commit", "-m", "add placeholder"])
1863 .current_dir(dir)
1864 .output()
1865 .expect("git commit");
1866
1867 std::fs::write(dir.join("lib.rs"), code).expect("write secret code");
1868
1869 let args = BugbotCheckArgs {
1870 path: dir.to_path_buf(),
1871 base_ref: "HEAD".to_string(),
1872 staged: false,
1873 max_findings: 50,
1874 no_fail: false,
1875 quiet: true,
1876 no_tools: true, tool_timeout: 60,
1878 };
1879
1880 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1881 let _ = result;
1886
1887 let err = BugbotExitError::CriticalFindings { count: 2 };
1889 assert_eq!(err.exit_code(), 3, "CriticalFindings exit code should be 3");
1890 }
1891
1892 #[test]
1893 fn test_l2_all_engines_registered() {
1894 use crate::commands::bugbot::l2::l2_engine_registry;
1896 let engines = l2_engine_registry();
1897 assert_eq!(
1898 engines.len(),
1899 1,
1900 "Registry should contain exactly 1 engine (TldrDifferentialEngine), got {}",
1901 engines.len()
1902 );
1903 assert_eq!(engines[0].name(), "TldrDifferentialEngine");
1904 }
1905
1906 #[test]
1907 fn test_l2_total_finding_types_matches_tldr_engine() {
1908 use crate::commands::bugbot::l2::l2_engine_registry;
1912 let engines = l2_engine_registry();
1913 let total: usize = engines.iter().map(|e| e.finding_types().len()).sum();
1914 assert_eq!(
1915 total, 11,
1916 "Total finding types across all engines should be 11 (TldrDifferentialEngine), got {}",
1917 total
1918 );
1919 }
1920
1921 #[test]
1922 fn test_l2_engine_names_unique() {
1923 use crate::commands::bugbot::l2::l2_engine_registry;
1925 let engines = l2_engine_registry();
1926 let mut names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
1927 let original_len = names.len();
1928 names.sort();
1929 names.dedup();
1930 assert_eq!(
1931 names.len(),
1932 original_len,
1933 "Engine names should be unique, found duplicates"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_severity_rank_ordering_complete() {
1939 assert!(
1942 severity_rank("critical") > severity_rank("high"),
1943 "critical should rank above high"
1944 );
1945 assert!(
1946 severity_rank("high") > severity_rank("medium"),
1947 "high should rank above medium"
1948 );
1949 assert!(
1950 severity_rank("medium") > severity_rank("low"),
1951 "medium should rank above low"
1952 );
1953 assert!(
1954 severity_rank("low") > severity_rank("info"),
1955 "low should rank above info"
1956 );
1957 assert!(
1958 severity_rank("info") > severity_rank("unknown"),
1959 "info should rank above unknown"
1960 );
1961 assert_eq!(
1962 severity_rank("unknown"),
1963 0,
1964 "unknown severity should have rank 0"
1965 );
1966 }
1967
1968 #[test]
1969 fn test_run_l2_engines_empty_context() {
1970 use crate::commands::bugbot::l2::context::FunctionDiff;
1972 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
1973 use std::collections::HashMap;
1974
1975 let engines = l2_engine_registry();
1976 let ctx = L2Context::new(
1977 PathBuf::from("/tmp/nonexistent"),
1978 Language::Rust,
1979 vec![],
1980 FunctionDiff {
1981 changed: vec![],
1982 inserted: vec![],
1983 deleted: vec![],
1984 },
1985 HashMap::new(),
1986 HashMap::new(),
1987 HashMap::new(),
1988 );
1989
1990 let (_findings, results) = run_l2_engines(&ctx, &engines);
1991
1992 assert_eq!(
1994 results.len(),
1995 engines.len(),
1996 "Should have one result per engine"
1997 );
1998
1999 for result in &results {
2001 assert!(!result.name.is_empty(), "Engine result should have a name");
2002 }
2003 }
2004
2005 #[test]
2010 fn test_l2_engine_failure_isolation() {
2011 use crate::commands::bugbot::l2::context::FunctionDiff;
2016 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2017 use std::collections::HashMap;
2018
2019 let engines = l2_engine_registry();
2020 let ctx = L2Context::new(
2021 PathBuf::from("/tmp/nonexistent"),
2022 Language::Rust,
2023 vec![],
2024 FunctionDiff {
2025 changed: vec![],
2026 inserted: vec![],
2027 deleted: vec![],
2028 },
2029 HashMap::new(),
2030 HashMap::new(),
2031 HashMap::new(),
2032 );
2033
2034 let (_findings, results) = run_l2_engines(&ctx, &engines);
2035
2036 assert_eq!(
2038 results.len(),
2039 engines.len(),
2040 "Every engine must produce a result even when others partially fail"
2041 );
2042
2043 for result in &results {
2045 assert!(
2046 !result.name.is_empty(),
2047 "Engine result must have a non-empty name"
2048 );
2049 assert!(
2050 !result.status.is_empty(),
2051 "Engine '{}' must have a non-empty status string",
2052 result.name
2053 );
2054 }
2055 }
2056
2057 #[test]
2058 fn test_l2_findings_merge_with_l1() {
2059 let l1_findings = vec![
2062 BugbotFinding {
2063 finding_type: "tool:clippy".to_string(),
2064 severity: "medium".to_string(),
2065 file: PathBuf::from("src/lib.rs"),
2066 function: String::new(),
2067 line: 10,
2068 message: "clippy lint".to_string(),
2069 evidence: serde_json::Value::Null,
2070 confidence: None,
2071 finding_id: None,
2072 },
2073 BugbotFinding {
2074 finding_type: "tool:cargo-audit".to_string(),
2075 severity: "low".to_string(),
2076 file: PathBuf::from("Cargo.lock"),
2077 function: String::new(),
2078 line: 1,
2079 message: "advisory".to_string(),
2080 evidence: serde_json::Value::Null,
2081 confidence: None,
2082 finding_id: None,
2083 },
2084 ];
2085
2086 let l2_findings = vec![
2087 BugbotFinding {
2088 finding_type: "signature-regression".to_string(),
2089 severity: "high".to_string(),
2090 file: PathBuf::from("src/api.rs"),
2091 function: "handle_request".to_string(),
2092 line: 42,
2093 message: "parameter removed".to_string(),
2094 evidence: serde_json::Value::Null,
2095 confidence: Some("CERTAIN".to_string()),
2096 finding_id: None,
2097 },
2098 BugbotFinding {
2099 finding_type: "born-dead".to_string(),
2100 severity: "low".to_string(),
2101 file: PathBuf::from("src/util.rs"),
2102 function: "unused_helper".to_string(),
2103 line: 5,
2104 message: "no callers".to_string(),
2105 evidence: serde_json::Value::Null,
2106 confidence: Some("CERTAIN".to_string()),
2107 finding_id: None,
2108 },
2109 ];
2110
2111 let mut all_findings = Vec::new();
2113 all_findings.extend(l1_findings);
2114 all_findings.extend(l2_findings);
2115
2116 all_findings.sort_by(|a, b| {
2118 severity_rank(&b.severity)
2119 .cmp(&severity_rank(&a.severity))
2120 .then(a.file.cmp(&b.file))
2121 .then(a.line.cmp(&b.line))
2122 });
2123
2124 assert_eq!(
2126 all_findings.len(),
2127 4,
2128 "merged list should contain all 4 findings"
2129 );
2130
2131 assert_eq!(
2133 all_findings[0].severity, "high",
2134 "highest severity finding should be first"
2135 );
2136 assert_eq!(
2137 all_findings[1].severity, "medium",
2138 "medium severity should be second"
2139 );
2140 assert_eq!(all_findings[2].severity, "low");
2142 assert_eq!(all_findings[3].severity, "low");
2143 assert!(
2144 all_findings[2].file <= all_findings[3].file,
2145 "low-severity findings should be sorted by file path"
2146 );
2147
2148 let l1_count = all_findings
2150 .iter()
2151 .filter(|f| f.finding_type.starts_with("tool:"))
2152 .count();
2153 let l2_count = all_findings
2154 .iter()
2155 .filter(|f| !f.finding_type.starts_with("tool:"))
2156 .count();
2157 assert_eq!(l1_count, 2, "should have 2 L1 findings");
2158 assert_eq!(l2_count, 2, "should have 2 L2 findings");
2159 }
2160
2161 #[test]
2162 fn test_l2_dedup_suppresses_born_dead_cascade() {
2163 use crate::commands::bugbot::l2::dedup::dedup_and_prioritize;
2167
2168 let findings = vec![
2169 BugbotFinding {
2170 finding_type: "born-dead".to_string(),
2171 severity: "low".to_string(),
2172 file: PathBuf::from("src/orphan.rs"),
2173 function: "orphan_fn".to_string(),
2174 line: 10,
2175 message: "function has no callers".to_string(),
2176 evidence: serde_json::Value::Null,
2177 confidence: Some("CERTAIN".to_string()),
2178 finding_id: None,
2179 },
2180 BugbotFinding {
2181 finding_type: "complexity-increase".to_string(),
2182 severity: "medium".to_string(),
2183 file: PathBuf::from("src/orphan.rs"),
2184 function: "orphan_fn".to_string(),
2185 line: 12,
2186 message: "cyclomatic complexity increased by 5".to_string(),
2187 evidence: serde_json::Value::Null,
2188 confidence: Some("LIKELY".to_string()),
2189 finding_id: None,
2190 },
2191 ];
2192
2193 let deduped = dedup_and_prioritize(findings, 0);
2194
2195 assert_eq!(
2197 deduped.len(),
2198 1,
2199 "born-dead should suppress all other findings for the same function, got {} findings",
2200 deduped.len()
2201 );
2202 assert_eq!(
2203 deduped[0].finding_type, "born-dead",
2204 "the surviving finding should be born-dead, not '{}'",
2205 deduped[0].finding_type
2206 );
2207 }
2208
2209 #[test]
2210 fn test_l2_composition_taint_plus_guard() {
2211 use crate::commands::bugbot::l2::composition::compose_findings;
2216
2217 let findings = vec![
2218 BugbotFinding {
2219 finding_type: "taint-flow".to_string(),
2220 severity: "high".to_string(),
2221 file: PathBuf::from("src/handler.rs"),
2222 function: "process_input".to_string(),
2223 line: 25,
2224 message: "user input flows to SQL query".to_string(),
2225 evidence: serde_json::json!({"source": "param:input", "sink": "sql_query"}),
2226 confidence: Some("LIKELY".to_string()),
2227 finding_id: None,
2228 },
2229 BugbotFinding {
2230 finding_type: "guard-removed".to_string(),
2231 severity: "medium".to_string(),
2232 file: PathBuf::from("src/handler.rs"),
2233 function: "process_input".to_string(),
2234 line: 28,
2235 message: "input validation guard was removed".to_string(),
2236 evidence: serde_json::json!({"guard": "validate_input()"}),
2237 confidence: Some("CERTAIN".to_string()),
2238 finding_id: None,
2239 },
2240 ];
2241
2242 let composed = compose_findings(findings);
2243
2244 assert_eq!(
2246 composed.len(),
2247 1,
2248 "taint-flow + guard-removed should compose into 1 finding, got {}",
2249 composed.len()
2250 );
2251
2252 let finding = &composed[0];
2253 assert_eq!(
2254 finding.finding_type, "unguarded-injection-path",
2255 "composed type should be unguarded-injection-path, got '{}'",
2256 finding.finding_type
2257 );
2258 assert_eq!(
2259 finding.severity, "critical",
2260 "unguarded-injection-path severity should be critical, got '{}'",
2261 finding.severity
2262 );
2263 assert_eq!(
2264 finding.confidence.as_deref(),
2265 Some("LIKELY"),
2266 "composed finding confidence should be LIKELY, got {:?}",
2267 finding.confidence
2268 );
2269
2270 assert!(
2272 finding.evidence.get("constituent_a").is_some(),
2273 "composed evidence should contain constituent_a"
2274 );
2275 assert!(
2276 finding.evidence.get("constituent_b").is_some(),
2277 "composed evidence should contain constituent_b"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_l2_language_gating_rust_skips_gvn() {
2283 use crate::commands::bugbot::l2::context::{FunctionDiff, InsertedFunction};
2287 use crate::commands::bugbot::l2::types::FunctionId;
2288 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2289 use std::collections::HashMap;
2290
2291 let source = "fn compute(x: i32) -> i32 { x + x }".to_string();
2294 let file = PathBuf::from("src/lib.rs");
2295 let func_id = FunctionId::new(file.clone(), "compute", 1);
2296
2297 let mut current_contents = HashMap::new();
2298 current_contents.insert(file.clone(), source.clone());
2299
2300 let ctx = L2Context::new(
2301 PathBuf::from("/tmp/test-gvn-gating"),
2302 Language::Rust,
2303 vec![file.clone()],
2304 FunctionDiff {
2305 changed: vec![],
2306 inserted: vec![InsertedFunction {
2307 id: func_id,
2308 name: "compute".to_string(),
2309 source,
2310 }],
2311 deleted: vec![],
2312 },
2313 HashMap::new(),
2314 current_contents,
2315 HashMap::new(),
2316 );
2317
2318 let engines = l2_engine_registry();
2319 let (findings, _results) = run_l2_engines(&ctx, &engines);
2320
2321 let gvn_findings: Vec<&BugbotFinding> = findings
2323 .iter()
2324 .filter(|f| f.finding_type == "redundant-computation")
2325 .collect();
2326
2327 assert!(
2328 gvn_findings.is_empty(),
2329 "Rust context should not produce redundant-computation findings (Python-only GVN), \
2330 but found {} such findings",
2331 gvn_findings.len()
2332 );
2333 }
2334
2335 #[test]
2336 fn test_l2_no_finding_types_overlap() {
2337 use crate::commands::bugbot::l2::l2_engine_registry;
2340 use std::collections::HashSet;
2341
2342 let engines = l2_engine_registry();
2343 let mut seen: HashSet<&str> = HashSet::new();
2344 let mut duplicates: Vec<String> = Vec::new();
2345
2346 for engine in &engines {
2347 for ft in engine.finding_types() {
2348 if !seen.insert(ft) {
2349 duplicates.push(format!(
2350 "'{}' claimed by engine '{}' but already registered",
2351 ft,
2352 engine.name()
2353 ));
2354 }
2355 }
2356 }
2357
2358 assert!(
2359 duplicates.is_empty(),
2360 "Finding type overlap detected: {}",
2361 duplicates.join("; ")
2362 );
2363 }
2364
2365 #[test]
2366 fn test_l2_all_finding_types_have_confidence() {
2367 use crate::commands::bugbot::l2::context::{FunctionDiff, InsertedFunction};
2372 use crate::commands::bugbot::l2::types::FunctionId;
2373 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2374 use std::collections::HashMap;
2375
2376 let source = "pub fn lonely_function(x: i32) -> i32 { x + 1 }".to_string();
2379 let file = PathBuf::from("src/lonely.rs");
2380 let func_id = FunctionId::new(file.clone(), "lonely_function", 1);
2381
2382 let mut current_contents = HashMap::new();
2383 current_contents.insert(file.clone(), source.clone());
2384
2385 let ctx = L2Context::new(
2386 PathBuf::from("/tmp/test-confidence"),
2387 Language::Rust,
2388 vec![file.clone()],
2389 FunctionDiff {
2390 changed: vec![],
2391 inserted: vec![InsertedFunction {
2392 id: func_id,
2393 name: "lonely_function".to_string(),
2394 source,
2395 }],
2396 deleted: vec![],
2397 },
2398 HashMap::new(),
2399 current_contents,
2400 HashMap::new(),
2401 );
2402
2403 let engines = l2_engine_registry();
2404 let (findings, _results) = run_l2_engines(&ctx, &engines);
2405
2406 let missing_confidence: Vec<String> = findings
2408 .iter()
2409 .filter(|f| f.confidence.is_none())
2410 .map(|f| format!("{}:{} ({})", f.file.display(), f.line, f.finding_type))
2411 .collect();
2412
2413 assert!(
2414 missing_confidence.is_empty(),
2415 "All L2 findings must have confidence set, but {} findings are missing it: {}",
2416 missing_confidence.len(),
2417 missing_confidence.join(", ")
2418 );
2419 }
2420
2421 #[test]
2426 fn test_l2_engines_sendable_to_thread() {
2427 use crate::commands::bugbot::l2::context::FunctionDiff;
2430 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2431 use std::collections::HashMap;
2432
2433 let engines = l2_engine_registry();
2434 let ctx = L2Context::new(
2435 PathBuf::from("/tmp/nonexistent"),
2436 Language::Rust,
2437 vec![],
2438 FunctionDiff {
2439 changed: vec![],
2440 inserted: vec![],
2441 deleted: vec![],
2442 },
2443 HashMap::new(),
2444 HashMap::new(),
2445 HashMap::new(),
2446 );
2447
2448 let handle = std::thread::spawn(move || run_l2_engines(&ctx, &engines));
2450
2451 let (findings, results) = handle.join().expect("L2 thread should not panic");
2452
2453 assert_eq!(
2455 results.len(),
2456 1,
2457 "L2 engines on thread should produce 1 result (DeltaEngine), got {}",
2458 results.len()
2459 );
2460 for result in &results {
2461 assert!(
2462 !result.name.is_empty(),
2463 "Engine result from thread should have a name"
2464 );
2465 }
2466 let _ = findings;
2468 }
2469
2470 #[test]
2471 fn test_l2_thread_panic_graceful_degradation() {
2472 let handle = std::thread::spawn(|| -> (Vec<BugbotFinding>, Vec<L2AnalyzerResult>) {
2474 panic!("simulated L2 engine panic");
2475 });
2476
2477 let (findings, results) = handle.join().unwrap_or_else(|_| (Vec::new(), Vec::new()));
2478
2479 assert!(
2480 findings.is_empty(),
2481 "Panicked thread should yield empty findings"
2482 );
2483 assert!(
2484 results.is_empty(),
2485 "Panicked thread should yield empty results"
2486 );
2487 }
2488
2489 #[test]
2490 fn test_l1_and_l2_parallel_both_contribute_to_report() {
2491 let l1_findings = vec![BugbotFinding {
2494 finding_type: "tool:clippy".to_string(),
2495 severity: "medium".to_string(),
2496 file: PathBuf::from("src/main.rs"),
2497 function: String::new(),
2498 line: 10,
2499 message: "unused variable".to_string(),
2500 evidence: serde_json::Value::Null,
2501 confidence: None,
2502 finding_id: None,
2503 }];
2504
2505 let l2_findings = vec![BugbotFinding {
2506 finding_type: "signature-regression".to_string(),
2507 severity: "high".to_string(),
2508 file: PathBuf::from("src/lib.rs"),
2509 function: "compute".to_string(),
2510 line: 5,
2511 message: "param removed".to_string(),
2512 evidence: serde_json::Value::Null,
2513 confidence: None,
2514 finding_id: None,
2515 }];
2516
2517 let mut findings: Vec<BugbotFinding> = Vec::new();
2519 findings.extend(l1_findings);
2520 findings.extend(l2_findings);
2521
2522 assert_eq!(
2523 findings.len(),
2524 2,
2525 "Merged findings should contain both L1 and L2"
2526 );
2527 assert!(
2528 findings.iter().any(|f| f.finding_type.starts_with("tool:")),
2529 "Should contain L1 finding"
2530 );
2531 assert!(
2532 findings
2533 .iter()
2534 .any(|f| f.finding_type == "signature-regression"),
2535 "Should contain L2 finding"
2536 );
2537 }
2538
2539 #[test]
2540 fn test_parallel_execution_integration() {
2541 use crate::commands::bugbot::l2::context::FunctionDiff;
2544 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2545 use std::collections::HashMap;
2546
2547 let engines = l2_engine_registry();
2548 let ctx = L2Context::new(
2549 PathBuf::from("/tmp/nonexistent"),
2550 Language::Rust,
2551 vec![],
2552 FunctionDiff {
2553 changed: vec![],
2554 inserted: vec![],
2555 deleted: vec![],
2556 },
2557 HashMap::new(),
2558 HashMap::new(),
2559 HashMap::new(),
2560 );
2561
2562 let l2_handle = std::thread::spawn(move || run_l2_engines(&ctx, &engines));
2564
2565 let (l1_raw, tool_results, tools_available, tools_missing) =
2567 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 5);
2568
2569 let (l2_engine_findings, l2_engine_results) = l2_handle
2571 .join()
2572 .unwrap_or_else(|_| (Vec::new(), Vec::new()));
2573
2574 let l1_bugbot: Vec<BugbotFinding> = l1_raw.into_iter().map(BugbotFinding::from).collect();
2576 let mut findings: Vec<BugbotFinding> = Vec::new();
2577 findings.extend(l1_bugbot);
2578 findings.extend(l2_engine_findings);
2579
2580 assert_eq!(
2582 l2_engine_results.len(),
2583 1,
2584 "L2 should produce 1 engine result (DeltaEngine), got {}",
2585 l2_engine_results.len()
2586 );
2587 let _ = tool_results;
2589 let _ = tools_available;
2590 let _ = tools_missing;
2591 }
2592
2593 #[test]
2594 fn test_no_tools_flag_still_runs_l2_on_thread() {
2595 use crate::commands::bugbot::l2::context::FunctionDiff;
2598 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2599 use std::collections::HashMap;
2600
2601 let engines = l2_engine_registry();
2602 let ctx = L2Context::new(
2603 PathBuf::from("/tmp/nonexistent"),
2604 Language::Rust,
2605 vec![],
2606 FunctionDiff {
2607 changed: vec![],
2608 inserted: vec![],
2609 deleted: vec![],
2610 },
2611 HashMap::new(),
2612 HashMap::new(),
2613 HashMap::new(),
2614 );
2615
2616 let l2_handle = std::thread::spawn(move || run_l2_engines(&ctx, &engines));
2618
2619 let (l1_raw, tool_results, _, _) =
2621 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", true, 60);
2622
2623 let (l2_findings, l2_results) = l2_handle
2625 .join()
2626 .unwrap_or_else(|_| (Vec::new(), Vec::new()));
2627
2628 assert!(
2629 l1_raw.is_empty(),
2630 "no_tools should produce empty L1 findings"
2631 );
2632 assert!(
2633 tool_results.is_empty(),
2634 "no_tools should produce empty tool_results"
2635 );
2636 assert_eq!(
2637 l2_results.len(),
2638 1,
2639 "L2 should run 1 engine (DeltaEngine), got {}",
2640 l2_results.len()
2641 );
2642 let _ = l2_findings;
2643 }
2644
2645 #[test]
2655 fn test_l2_all_engines_budget() {
2656 use crate::commands::bugbot::l2::context::{
2657 FunctionChange, FunctionDiff, InsertedFunction,
2658 };
2659 use crate::commands::bugbot::l2::types::FunctionId;
2660 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2661 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
2662 use std::collections::HashMap;
2663 use std::time::Duration;
2664
2665 let num_functions: usize = 50;
2667 let mut changed_functions = Vec::with_capacity(num_functions);
2668 let mut inserted_functions = Vec::with_capacity(num_functions / 5);
2669 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
2670 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
2671 let mut ast_changes: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
2672 let mut changed_files: Vec<PathBuf> = Vec::new();
2673
2674 let files_count = 10;
2676 for file_idx in 0..files_count {
2677 let file_path = PathBuf::from(format!("src/module_{}.rs", file_idx));
2678 changed_files.push(file_path.clone());
2679
2680 let mut baseline_src = String::new();
2681 let mut current_src = String::new();
2682 let mut file_ast_changes = Vec::new();
2683
2684 let funcs_per_file = num_functions / files_count;
2685 for func_idx in 0..funcs_per_file {
2686 let global_idx = file_idx * funcs_per_file + func_idx;
2687 let func_name = format!("process_item_{}", global_idx);
2688 let def_line = func_idx * 10 + 1;
2689
2690 let old_source = format!(
2692 "fn {}(input: &str) -> Result<(), Error> {{\n \
2693 let data = parse(input)?;\n \
2694 validate(&data)?;\n \
2695 Ok(())\n\
2696 }}\n",
2697 func_name
2698 );
2699
2700 let new_source = format!(
2702 "fn {}(input: &str, config: &Config) -> Result<(), Error> {{\n \
2703 let data = parse(input)?;\n \
2704 if config.strict {{\n \
2705 validate_strict(&data)?;\n \
2706 }} else {{\n \
2707 validate(&data)?;\n \
2708 }}\n \
2709 Ok(())\n\
2710 }}\n",
2711 func_name
2712 );
2713
2714 baseline_src.push_str(&old_source);
2715 baseline_src.push('\n');
2716 current_src.push_str(&new_source);
2717 current_src.push('\n');
2718
2719 let fid = FunctionId::new(file_path.clone(), func_name.clone(), def_line);
2720
2721 changed_functions.push(FunctionChange {
2722 id: fid,
2723 name: func_name.clone(),
2724 old_source: old_source.clone(),
2725 new_source: new_source.clone(),
2726 });
2727
2728 file_ast_changes.push(ASTChange {
2730 change_type: ChangeType::Update,
2731 node_kind: NodeKind::Function,
2732 name: Some(func_name.clone()),
2733 old_location: Some(Location::new(
2734 file_path.to_string_lossy().to_string(),
2735 def_line as u32,
2736 )),
2737 new_location: Some(Location::new(
2738 file_path.to_string_lossy().to_string(),
2739 def_line as u32,
2740 )),
2741 old_text: Some(old_source),
2742 new_text: Some(new_source),
2743 similarity: Some(0.85),
2744 children: None,
2745 base_changes: None,
2746 });
2747
2748 if global_idx.is_multiple_of(10) {
2750 let ins_name = format!("new_helper_{}", global_idx);
2751 let ins_source = format!("fn {}(x: i32) -> i32 {{\n x * 2\n}}\n", ins_name);
2752 inserted_functions.push(InsertedFunction {
2753 id: FunctionId::new(file_path.clone(), ins_name.clone(), def_line + 100),
2754 name: ins_name,
2755 source: ins_source,
2756 });
2757 }
2758 }
2759
2760 baseline_contents.insert(file_path.clone(), baseline_src);
2761 current_contents.insert(file_path.clone(), current_src);
2762 ast_changes.insert(file_path, file_ast_changes);
2763 }
2764
2765 let ctx = L2Context::new(
2766 PathBuf::from("/tmp/bench-project"),
2767 Language::Rust,
2768 changed_files,
2769 FunctionDiff {
2770 changed: changed_functions,
2771 inserted: inserted_functions,
2772 deleted: vec![],
2773 },
2774 baseline_contents,
2775 current_contents,
2776 ast_changes,
2777 );
2778
2779 let all_engines = l2_engine_registry();
2781
2782 assert_eq!(
2783 all_engines.len(),
2784 1,
2785 "Expected exactly 1 engine (DeltaEngine), got {}",
2786 all_engines.len()
2787 );
2788
2789 let start = Instant::now();
2791 let (findings, results) = run_l2_engines(&ctx, &all_engines);
2792 let elapsed = start.elapsed();
2793
2794 assert_eq!(
2798 results.len(),
2799 all_engines.len(),
2800 "Every engine must produce a result"
2801 );
2802
2803 let engines_that_ran = results
2805 .iter()
2806 .filter(|r| r.functions_analyzed > 0 || r.finding_count > 0)
2807 .count();
2808 assert!(
2809 engines_that_ran > 0,
2810 "At least one engine must have analyzed functions, \
2811 but all were skipped: {:?}",
2812 results
2813 .iter()
2814 .map(|r| format!("{}:{}", r.name, r.status))
2815 .collect::<Vec<_>>()
2816 );
2817
2818 let budget = if cfg!(debug_assertions) {
2821 Duration::from_millis(5000)
2822 } else {
2823 Duration::from_millis(2000)
2824 };
2825
2826 assert!(
2827 elapsed < budget,
2828 "All engines took {:?} which exceeds the {:?} budget \
2829 (release target: <2000ms). Engine breakdown: {:?}",
2830 elapsed,
2831 budget,
2832 results
2833 .iter()
2834 .map(|r| format!("{}={}ms", r.name, r.duration_ms))
2835 .collect::<Vec<_>>()
2836 );
2837
2838 let total_finding_count: usize = results.iter().map(|r| r.finding_count).sum();
2840 assert_eq!(
2841 findings.len(),
2842 total_finding_count,
2843 "Merged findings count must equal sum of per-engine finding counts"
2844 );
2845 }
2846
2847 #[test]
2856 fn test_flow_engine_realistic_workload() {
2857 use crate::commands::bugbot::l2::context::{
2858 FunctionChange, FunctionDiff, InsertedFunction,
2859 };
2860 use crate::commands::bugbot::l2::types::FunctionId;
2861 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2862 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
2863 use std::collections::HashMap;
2864
2865 type ComplexTemplate = Box<dyn Fn(usize) -> (String, String)>;
2871 let complex_templates: Vec<ComplexTemplate> = vec![
2872 Box::new(|idx: usize| {
2874 let old = format!(
2875 r#"fn deserialize_{idx}(input: &[u8]) -> Result<Value, Error> {{
2876 let mut pos = 0;
2877 let tag = input.get(pos).ok_or(Error::Eof)?;
2878 pos += 1;
2879 match tag {{
2880 0x01 => {{
2881 let len = input.get(pos).copied().unwrap_or(0) as usize;
2882 pos += 1;
2883 let data = &input[pos..pos + len];
2884 Ok(Value::String(std::str::from_utf8(data)?.to_string()))
2885 }}
2886 0x02 => {{
2887 let n = i32::from_le_bytes(input[pos..pos+4].try_into()?);
2888 Ok(Value::Int(n))
2889 }}
2890 0x03 => {{
2891 let count = input.get(pos).copied().unwrap_or(0) as usize;
2892 pos += 1;
2893 let mut items = Vec::with_capacity(count);
2894 for _ in 0..count {{
2895 let item = deserialize_{idx}(&input[pos..])?;
2896 items.push(item);
2897 }}
2898 Ok(Value::Array(items))
2899 }}
2900 _ => Err(Error::UnknownTag(*tag)),
2901 }}
2902}}"#
2903 );
2904 let new = format!(
2905 r#"fn deserialize_{idx}(input: &[u8], opts: &Options) -> Result<Value, Error> {{
2906 let mut pos = 0;
2907 let tag = input.get(pos).ok_or(Error::Eof)?;
2908 pos += 1;
2909 match tag {{
2910 0x01 => {{
2911 let len = input.get(pos).copied().unwrap_or(0) as usize;
2912 pos += 1;
2913 if len > opts.max_string_len {{
2914 return Err(Error::TooLong(len));
2915 }}
2916 let data = &input[pos..pos + len];
2917 Ok(Value::String(std::str::from_utf8(data)?.to_string()))
2918 }}
2919 0x02 => {{
2920 let n = i32::from_le_bytes(input[pos..pos+4].try_into()?);
2921 if opts.strict && n < 0 {{
2922 return Err(Error::NegativeInt(n));
2923 }}
2924 Ok(Value::Int(n))
2925 }}
2926 0x03 => {{
2927 let count = input.get(pos).copied().unwrap_or(0) as usize;
2928 if count > opts.max_array_len {{
2929 return Err(Error::TooMany(count));
2930 }}
2931 pos += 1;
2932 let mut items = Vec::with_capacity(count);
2933 for _ in 0..count {{
2934 let item = deserialize_{idx}(&input[pos..], opts)?;
2935 items.push(item);
2936 }}
2937 Ok(Value::Array(items))
2938 }}
2939 _ => Err(Error::UnknownTag(*tag)),
2940 }}
2941}}"#
2942 );
2943 (old, new)
2944 }),
2945 Box::new(|idx: usize| {
2947 let old = format!(
2948 r#"fn process_state_{idx}(events: &[Event]) -> Result<State, Error> {{
2949 let mut state = State::Init;
2950 let mut retries = 0;
2951 let mut last_error = None;
2952 for event in events {{
2953 state = match (state, event) {{
2954 (State::Init, Event::Start) => State::Running,
2955 (State::Running, Event::Pause) => State::Paused,
2956 (State::Paused, Event::Resume) => State::Running,
2957 (State::Running, Event::Error(e)) => {{
2958 last_error = Some(e.clone());
2959 retries += 1;
2960 if retries > 3 {{
2961 return Err(Error::TooManyRetries(last_error.unwrap()));
2962 }}
2963 State::Running
2964 }}
2965 (State::Running, Event::Done) => State::Complete,
2966 (s, _) => s,
2967 }};
2968 }}
2969 Ok(state)
2970}}"#
2971 );
2972 let new = format!(
2973 r#"fn process_state_{idx}(events: &[Event], config: &Config) -> Result<State, Error> {{
2974 let mut state = State::Init;
2975 let mut retries = 0;
2976 let mut last_error = None;
2977 let max_retries = config.max_retries.unwrap_or(3);
2978 for event in events {{
2979 state = match (state, event) {{
2980 (State::Init, Event::Start) => {{
2981 if config.require_auth && !config.authenticated {{
2982 return Err(Error::Unauthorized);
2983 }}
2984 State::Running
2985 }}
2986 (State::Running, Event::Pause) => State::Paused,
2987 (State::Paused, Event::Resume) => {{
2988 retries = 0;
2989 State::Running
2990 }}
2991 (State::Running, Event::Error(e)) => {{
2992 last_error = Some(e.clone());
2993 retries += 1;
2994 if retries > max_retries {{
2995 return Err(Error::TooManyRetries(last_error.unwrap()));
2996 }}
2997 State::Running
2998 }}
2999 (State::Running, Event::Done) => State::Complete,
3000 (s, _) => s,
3001 }};
3002 }}
3003 Ok(state)
3004}}"#
3005 );
3006 (old, new)
3007 }),
3008 Box::new(|idx: usize| {
3010 let old = format!(
3011 r#"fn read_config_{idx}(path: &str) -> Result<Config, Error> {{
3012 let file = File::open(path)?;
3013 let reader = BufReader::new(file);
3014 let mut lines = Vec::new();
3015 for line in reader.lines() {{
3016 let line = line?;
3017 if line.starts_with('#') {{
3018 continue;
3019 }}
3020 if line.is_empty() {{
3021 break;
3022 }}
3023 lines.push(line);
3024 }}
3025 let parsed = parse_config(&lines)?;
3026 validate_config(&parsed)?;
3027 Ok(parsed)
3028}}"#
3029 );
3030 let new = format!(
3031 r#"fn read_config_{idx}(path: &str, env: &Env) -> Result<Config, Error> {{
3032 let file = File::open(path)?;
3033 let reader = BufReader::new(file);
3034 let mut lines = Vec::new();
3035 let mut saw_section = false;
3036 for line in reader.lines() {{
3037 let line = line?;
3038 if line.starts_with('#') {{
3039 continue;
3040 }}
3041 if line.starts_with('[') {{
3042 if saw_section {{
3043 break;
3044 }}
3045 saw_section = true;
3046 continue;
3047 }}
3048 if line.is_empty() && !saw_section {{
3049 break;
3050 }}
3051 let resolved = if line.contains("${{") {{
3052 env.resolve_vars(&line)?
3053 }} else {{
3054 line
3055 }};
3056 lines.push(resolved);
3057 }}
3058 let parsed = parse_config(&lines)?;
3059 validate_config(&parsed)?;
3060 Ok(parsed)
3061}}"#
3062 );
3063 (old, new)
3064 }),
3065 Box::new(|idx: usize| {
3067 let old = format!(
3068 r#"fn compute_metrics_{idx}(data: &[f64]) -> Metrics {{
3069 let sum: f64 = data.iter().sum();
3070 let count = data.len();
3071 let mean = sum / count as f64;
3072 let variance = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count as f64;
3073 let stddev = variance.sqrt();
3074 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
3075 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3076 let range = max - min;
3077 Metrics {{ mean, stddev, min, max, range }}
3078}}"#
3079 );
3080 let new = format!(
3081 r#"fn compute_metrics_{idx}(data: &[f64], opts: &MetricOpts) -> Result<Metrics, Error> {{
3082 if data.is_empty() {{
3083 return Err(Error::EmptyData);
3084 }}
3085 let sum: f64 = data.iter().sum();
3086 let count = data.len();
3087 let mean = sum / count as f64;
3088 let trimmed = if opts.trim_outliers {{
3089 let lo = mean - 2.0 * opts.threshold;
3090 let hi = mean + 2.0 * opts.threshold;
3091 data.iter().filter(|&&x| x >= lo && x <= hi).copied().collect::<Vec<_>>()
3092 }} else {{
3093 data.to_vec()
3094 }};
3095 let adj_count = trimmed.len();
3096 if adj_count == 0 {{
3097 return Err(Error::AllOutliers);
3098 }}
3099 let adj_mean = trimmed.iter().sum::<f64>() / adj_count as f64;
3100 let variance = trimmed.iter().map(|x| (x - adj_mean).powi(2)).sum::<f64>() / adj_count as f64;
3101 let stddev = variance.sqrt();
3102 let min = trimmed.iter().copied().fold(f64::INFINITY, f64::min);
3103 let max = trimmed.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3104 let range = max - min;
3105 let cv = if adj_mean.abs() > f64::EPSILON {{ stddev / adj_mean }} else {{ 0.0 }};
3106 Ok(Metrics {{ mean: adj_mean, stddev, min, max, range, cv }})
3107}}"#
3108 );
3109 (old, new)
3110 }),
3111 Box::new(|idx: usize| {
3113 let old = format!(
3114 r#"fn handle_request_{idx}(req: &Request) -> Result<Response, Error> {{
3115 let auth = validate_auth(&req.headers)?;
3116 let body = parse_body(&req.body)?;
3117 let user = lookup_user(auth.user_id)?;
3118 let result = execute_query(&user, &body.query)?;
3119 let formatted = format_response(&result)?;
3120 Ok(Response::new(200, formatted))
3121}}"#
3122 );
3123 let new = format!(
3124 r#"fn handle_request_{idx}(req: &Request, ctx: &Context) -> Result<Response, Error> {{
3125 let auth = validate_auth(&req.headers)?;
3126 if auth.expired() {{
3127 return Ok(Response::new(401, "Token expired".into()));
3128 }}
3129 let body = parse_body(&req.body)?;
3130 let user = lookup_user(auth.user_id)?;
3131 if !user.has_permission(&body.query) {{
3132 return Ok(Response::new(403, "Forbidden".into()));
3133 }}
3134 let result = if ctx.read_only {{
3135 execute_read_query(&user, &body.query)?
3136 }} else {{
3137 execute_query(&user, &body.query)?
3138 }};
3139 let formatted = format_response(&result)?;
3140 ctx.metrics.record_latency(req.start.elapsed());
3141 Ok(Response::new(200, formatted))
3142}}"#
3143 );
3144 (old, new)
3145 }),
3146 ];
3147
3148 let num_functions: usize = 50;
3150 let num_templates = complex_templates.len();
3151 let mut changed_functions = Vec::with_capacity(num_functions);
3152 let mut inserted_functions = Vec::with_capacity(num_functions / 5);
3153 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
3154 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
3155 let mut ast_changes: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3156 let mut changed_files: Vec<PathBuf> = Vec::new();
3157
3158 let files_count = 10;
3159 for file_idx in 0..files_count {
3160 let file_path = PathBuf::from(format!("src/service_{}.rs", file_idx));
3161 changed_files.push(file_path.clone());
3162
3163 let mut baseline_src = String::new();
3164 let mut current_src = String::new();
3165 let mut file_ast_changes = Vec::new();
3166
3167 let funcs_per_file = num_functions / files_count;
3168 for func_idx in 0..funcs_per_file {
3169 let global_idx = file_idx * funcs_per_file + func_idx;
3170 let template_idx = global_idx % num_templates;
3171 let (old_source, new_source) = complex_templates[template_idx](global_idx);
3172
3173 let func_name = old_source
3175 .strip_prefix("fn ")
3176 .and_then(|s| s.split('(').next())
3177 .unwrap_or(&format!("func_{}", global_idx))
3178 .to_string();
3179
3180 let def_line = baseline_src.lines().count() + 1;
3181
3182 baseline_src.push_str(&old_source);
3183 baseline_src.push_str("\n\n");
3184 current_src.push_str(&new_source);
3185 current_src.push_str("\n\n");
3186
3187 let fid = FunctionId::new(file_path.clone(), func_name.clone(), def_line);
3188
3189 changed_functions.push(FunctionChange {
3190 id: fid,
3191 name: func_name.clone(),
3192 old_source: old_source.clone(),
3193 new_source: new_source.clone(),
3194 });
3195
3196 file_ast_changes.push(ASTChange {
3197 change_type: ChangeType::Update,
3198 node_kind: NodeKind::Function,
3199 name: Some(func_name.clone()),
3200 old_location: Some(Location::new(
3201 file_path.to_string_lossy().to_string(),
3202 def_line as u32,
3203 )),
3204 new_location: Some(Location::new(
3205 file_path.to_string_lossy().to_string(),
3206 def_line as u32,
3207 )),
3208 old_text: Some(old_source),
3209 new_text: Some(new_source),
3210 similarity: Some(0.75),
3211 children: None,
3212 base_changes: None,
3213 });
3214
3215 if global_idx.is_multiple_of(10) {
3216 let ins_name = format!("new_helper_{}", global_idx);
3217 let ins_source = format!("fn {}(x: i32) -> i32 {{\n x * 2\n}}\n", ins_name);
3218 inserted_functions.push(InsertedFunction {
3219 id: FunctionId::new(file_path.clone(), ins_name.clone(), def_line + 200),
3220 name: ins_name,
3221 source: ins_source,
3222 });
3223 }
3224 }
3225
3226 baseline_contents.insert(file_path.clone(), baseline_src);
3227 current_contents.insert(file_path.clone(), current_src);
3228 ast_changes.insert(file_path, file_ast_changes);
3229 }
3230
3231 let ctx = L2Context::new(
3232 PathBuf::from("/tmp/bench-deferred-realistic"),
3233 Language::Rust,
3234 changed_files,
3235 FunctionDiff {
3236 changed: changed_functions,
3237 inserted: inserted_functions,
3238 deleted: vec![],
3239 },
3240 baseline_contents,
3241 current_contents,
3242 ast_changes,
3243 );
3244
3245 let all_engines = l2_engine_registry();
3247
3248 let start = Instant::now();
3249 let (findings, results) = run_l2_engines(&ctx, &all_engines);
3250 let elapsed = start.elapsed();
3251
3252 let engines_ran = results.iter().filter(|r| r.functions_analyzed > 0).count();
3254 assert!(engines_ran > 0, "All engines skipped on realistic workload");
3255
3256 eprintln!(
3258 "\n[realistic-bench] Total={:?}\n Engines: {:?}\n Findings: {}",
3259 elapsed,
3260 results
3261 .iter()
3262 .map(|r| format!(
3263 "{}={}ms(a={},f={})",
3264 r.name, r.duration_ms, r.functions_analyzed, r.finding_count
3265 ))
3266 .collect::<Vec<_>>(),
3267 findings.len(),
3268 );
3269
3270 assert_eq!(
3272 results.len(),
3273 all_engines.len(),
3274 "Every engine must produce a result"
3275 );
3276
3277 let total_finding_count: usize = results.iter().map(|r| r.finding_count).sum();
3284 assert_eq!(
3285 findings.len(),
3286 total_finding_count,
3287 "Merged findings count must equal sum of per-engine finding counts"
3288 );
3289 }
3290
3291 #[test]
3297 fn test_all_engines_produce_results() {
3298 use crate::commands::bugbot::l2::context::FunctionDiff;
3299 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3300 use std::collections::HashMap;
3301
3302 let engines = l2_engine_registry();
3303 let ctx = L2Context::new(
3304 PathBuf::from("/tmp/test-engine-results"),
3305 Language::Rust,
3306 vec![],
3307 FunctionDiff {
3308 changed: vec![],
3309 inserted: vec![],
3310 deleted: vec![],
3311 },
3312 HashMap::new(),
3313 HashMap::new(),
3314 HashMap::new(),
3315 );
3316
3317 let (_findings, results) = run_l2_engines(&ctx, &engines);
3318
3319 assert_eq!(
3321 results.len(),
3322 engines.len(),
3323 "All engines should have results"
3324 );
3325
3326 let engine_names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
3328 for result in &results {
3329 assert!(
3330 engine_names.contains(&result.name.as_str()),
3331 "Result for unknown engine '{}' -- not in registry",
3332 result.name
3333 );
3334 }
3335
3336 assert_eq!(
3338 engines.len(),
3339 1,
3340 "Should have exactly 1 engine (DeltaEngine), got {}",
3341 engines.len()
3342 );
3343 }
3344
3345 #[test]
3347 fn test_engines_run_synchronously_without_daemon() {
3348 use crate::commands::bugbot::l2::context::FunctionDiff;
3349 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3350 use std::collections::HashMap;
3351
3352 let engines = l2_engine_registry();
3353 let ctx = L2Context::new(
3354 PathBuf::from("/tmp/test-sync-no-daemon"),
3355 Language::Rust,
3356 vec![],
3357 FunctionDiff {
3358 changed: vec![],
3359 inserted: vec![],
3360 deleted: vec![],
3361 },
3362 HashMap::new(),
3363 HashMap::new(),
3364 HashMap::new(),
3365 );
3366
3367 assert!(!ctx.daemon_available());
3369
3370 let (_findings, results) = run_l2_engines(&ctx, &engines);
3371
3372 assert_eq!(
3374 results.len(),
3375 engines.len(),
3376 "All engines should have run even without daemon"
3377 );
3378
3379 for result in &results {
3380 assert!(
3381 !result.status.is_empty(),
3382 "Engine '{}' should have a status after running synchronously",
3383 result.name
3384 );
3385 }
3386 }
3387
3388 #[test]
3390 fn test_deferred_engines_use_daemon_cache() {
3391 use crate::commands::bugbot::l2::context::FunctionDiff;
3392 use crate::commands::bugbot::l2::daemon_client::DaemonClient;
3393 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3394 use std::collections::HashMap;
3395
3396 struct AvailableDaemon;
3398 impl DaemonClient for AvailableDaemon {
3399 fn is_available(&self) -> bool {
3400 true
3401 }
3402 fn query_call_graph(&self) -> Option<tldr_core::ProjectCallGraph> {
3403 None
3404 }
3405 fn query_cfg(
3406 &self,
3407 _fid: &super::super::l2::types::FunctionId,
3408 ) -> Option<tldr_core::CfgInfo> {
3409 None
3410 }
3411 fn query_dfg(
3412 &self,
3413 _fid: &super::super::l2::types::FunctionId,
3414 ) -> Option<tldr_core::DfgInfo> {
3415 None
3416 }
3417 fn query_ssa(
3418 &self,
3419 _fid: &super::super::l2::types::FunctionId,
3420 ) -> Option<tldr_core::ssa::SsaFunction> {
3421 None
3422 }
3423 fn notify_changed_files(&self, _files: &[PathBuf]) {}
3424 }
3425
3426 let engines = l2_engine_registry();
3427 let ctx = L2Context::new(
3428 PathBuf::from("/tmp/test-daemon-cache"),
3429 Language::Rust,
3430 vec![],
3431 FunctionDiff {
3432 changed: vec![],
3433 inserted: vec![],
3434 deleted: vec![],
3435 },
3436 HashMap::new(),
3437 HashMap::new(),
3438 HashMap::new(),
3439 )
3440 .with_daemon(Box::new(AvailableDaemon));
3441
3442 assert!(ctx.daemon_available());
3443
3444 let (_findings, results) = run_l2_engines(&ctx, &engines);
3445
3446 assert_eq!(
3448 results.len(),
3449 engines.len(),
3450 "All engines should run even with daemon available"
3451 );
3452 }
3453
3454 #[test]
3456 fn test_daemon_client_creation_factory() {
3457 use crate::commands::bugbot::l2::daemon_client::create_daemon_client;
3458
3459 let client = create_daemon_client(std::path::Path::new("/tmp/nonexistent-project-xyz"));
3461 assert!(!client.is_available());
3462 }
3463
3464 #[test]
3469 fn test_build_function_diff_from_ast_changes_insert() {
3470 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3471
3472 let project = PathBuf::from("/project");
3473 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3474 all_diffs.insert(
3475 PathBuf::from("/project/src/lib.rs"),
3476 vec![ASTChange {
3477 change_type: ChangeType::Insert,
3478 node_kind: NodeKind::Function,
3479 name: Some("new_func".to_string()),
3480 old_location: None,
3481 new_location: Some(Location::new("src/lib.rs", 10)),
3482 old_text: None,
3483 new_text: Some("fn new_func() { }".to_string()),
3484 similarity: None,
3485 children: None,
3486 base_changes: None,
3487 }],
3488 );
3489
3490 let diff = build_function_diff(&all_diffs, &project);
3491
3492 assert_eq!(diff.inserted.len(), 1, "Should have 1 inserted function");
3493 assert_eq!(diff.changed.len(), 0, "Should have 0 changed functions");
3494 assert_eq!(diff.deleted.len(), 0, "Should have 0 deleted functions");
3495 assert_eq!(diff.inserted[0].name, "new_func");
3496 assert_eq!(diff.inserted[0].source, "fn new_func() { }");
3497 assert_eq!(
3498 diff.inserted[0].id.file,
3499 PathBuf::from("src/lib.rs"),
3500 "FunctionId file should be relative"
3501 );
3502 assert_eq!(diff.inserted[0].id.def_line, 10);
3503 }
3504
3505 #[test]
3506 fn test_build_function_diff_from_ast_changes_update() {
3507 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3508
3509 let project = PathBuf::from("/project");
3510 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3511 all_diffs.insert(
3512 PathBuf::from("/project/src/main.rs"),
3513 vec![ASTChange {
3514 change_type: ChangeType::Update,
3515 node_kind: NodeKind::Function,
3516 name: Some("existing_fn".to_string()),
3517 old_location: Some(Location::new("src/main.rs", 5)),
3518 new_location: Some(Location::new("src/main.rs", 5)),
3519 old_text: Some("fn existing_fn() { old }".to_string()),
3520 new_text: Some("fn existing_fn() { new }".to_string()),
3521 similarity: None,
3522 children: None,
3523 base_changes: None,
3524 }],
3525 );
3526
3527 let diff = build_function_diff(&all_diffs, &project);
3528
3529 assert_eq!(diff.changed.len(), 1, "Should have 1 changed function");
3530 assert_eq!(diff.inserted.len(), 0);
3531 assert_eq!(diff.deleted.len(), 0);
3532 assert_eq!(diff.changed[0].name, "existing_fn");
3533 assert_eq!(diff.changed[0].old_source, "fn existing_fn() { old }");
3534 assert_eq!(diff.changed[0].new_source, "fn existing_fn() { new }");
3535 assert_eq!(
3536 diff.changed[0].id.file,
3537 PathBuf::from("src/main.rs"),
3538 "FunctionId file should be relative"
3539 );
3540 }
3541
3542 #[test]
3543 fn test_build_function_diff_from_ast_changes_delete() {
3544 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3545
3546 let project = PathBuf::from("/project");
3547 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3548 all_diffs.insert(
3549 PathBuf::from("/project/src/old.rs"),
3550 vec![ASTChange {
3551 change_type: ChangeType::Delete,
3552 node_kind: NodeKind::Function,
3553 name: Some("removed_fn".to_string()),
3554 old_location: Some(Location::new("src/old.rs", 20)),
3555 new_location: None,
3556 old_text: Some("fn removed_fn() { }".to_string()),
3557 new_text: None,
3558 similarity: None,
3559 children: None,
3560 base_changes: None,
3561 }],
3562 );
3563
3564 let diff = build_function_diff(&all_diffs, &project);
3565
3566 assert_eq!(diff.deleted.len(), 1, "Should have 1 deleted function");
3567 assert_eq!(diff.changed.len(), 0);
3568 assert_eq!(diff.inserted.len(), 0);
3569 assert_eq!(diff.deleted[0].name, "removed_fn");
3570 assert_eq!(
3571 diff.deleted[0].id.file,
3572 PathBuf::from("src/old.rs"),
3573 "FunctionId file should be relative"
3574 );
3575 }
3576
3577 #[test]
3578 fn test_build_function_diff_skips_non_function_nodes() {
3579 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3580
3581 let project = PathBuf::from("/project");
3582 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3583 all_diffs.insert(
3584 PathBuf::from("/project/src/lib.rs"),
3585 vec![
3586 ASTChange {
3588 change_type: ChangeType::Insert,
3589 node_kind: NodeKind::Class,
3590 name: Some("MyClass".to_string()),
3591 old_location: None,
3592 new_location: Some(Location::new("src/lib.rs", 1)),
3593 old_text: None,
3594 new_text: Some("class MyClass {}".to_string()),
3595 similarity: None,
3596 children: None,
3597 base_changes: None,
3598 },
3599 ASTChange {
3601 change_type: ChangeType::Update,
3602 node_kind: NodeKind::Statement,
3603 name: Some("let x".to_string()),
3604 old_location: Some(Location::new("src/lib.rs", 10)),
3605 new_location: Some(Location::new("src/lib.rs", 10)),
3606 old_text: Some("let x = 1;".to_string()),
3607 new_text: Some("let x = 2;".to_string()),
3608 similarity: None,
3609 children: None,
3610 base_changes: None,
3611 },
3612 ASTChange {
3614 change_type: ChangeType::Insert,
3615 node_kind: NodeKind::Function,
3616 name: Some("real_fn".to_string()),
3617 old_location: None,
3618 new_location: Some(Location::new("src/lib.rs", 20)),
3619 old_text: None,
3620 new_text: Some("fn real_fn() {}".to_string()),
3621 similarity: None,
3622 children: None,
3623 base_changes: None,
3624 },
3625 ],
3626 );
3627
3628 let diff = build_function_diff(&all_diffs, &project);
3629
3630 assert_eq!(
3631 diff.inserted.len(),
3632 1,
3633 "Only function/method nodes should be included"
3634 );
3635 assert_eq!(diff.inserted[0].name, "real_fn");
3636 }
3637
3638 #[test]
3639 fn test_build_function_diff_includes_method_nodes() {
3640 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3641
3642 let project = PathBuf::from("/project");
3643 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3644 all_diffs.insert(
3645 PathBuf::from("/project/src/impl.rs"),
3646 vec![ASTChange {
3647 change_type: ChangeType::Update,
3648 node_kind: NodeKind::Method,
3649 name: Some("MyStruct::do_thing".to_string()),
3650 old_location: Some(Location::new("src/impl.rs", 15)),
3651 new_location: Some(Location::new("src/impl.rs", 15)),
3652 old_text: Some("fn do_thing(&self) { old }".to_string()),
3653 new_text: Some("fn do_thing(&self) { new }".to_string()),
3654 similarity: None,
3655 children: None,
3656 base_changes: None,
3657 }],
3658 );
3659
3660 let diff = build_function_diff(&all_diffs, &project);
3661
3662 assert_eq!(diff.changed.len(), 1, "Method nodes should be included");
3663 assert_eq!(diff.changed[0].name, "MyStruct::do_thing");
3664 }
3665
3666 #[test]
3667 fn test_build_function_diff_skips_unnamed_changes() {
3668 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3669
3670 let project = PathBuf::from("/project");
3671 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3672 all_diffs.insert(
3673 PathBuf::from("/project/src/lib.rs"),
3674 vec![ASTChange {
3675 change_type: ChangeType::Insert,
3676 node_kind: NodeKind::Function,
3677 name: None, old_location: None,
3679 new_location: Some(Location::new("src/lib.rs", 1)),
3680 old_text: None,
3681 new_text: Some("fn() {}".to_string()),
3682 similarity: None,
3683 children: None,
3684 base_changes: None,
3685 }],
3686 );
3687
3688 let diff = build_function_diff(&all_diffs, &project);
3689
3690 assert_eq!(
3691 diff.inserted.len(),
3692 0,
3693 "Unnamed function changes should be skipped"
3694 );
3695 }
3696
3697 #[test]
3698 fn test_build_function_diff_move_with_both_texts_becomes_update() {
3699 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3700
3701 let project = PathBuf::from("/project");
3702 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3703 all_diffs.insert(
3704 PathBuf::from("/project/src/lib.rs"),
3705 vec![ASTChange {
3706 change_type: ChangeType::Move,
3707 node_kind: NodeKind::Function,
3708 name: Some("moved_fn".to_string()),
3709 old_location: Some(Location::new("src/lib.rs", 10)),
3710 new_location: Some(Location::new("src/lib.rs", 50)),
3711 old_text: Some("fn moved_fn() { a }".to_string()),
3712 new_text: Some("fn moved_fn() { b }".to_string()),
3713 similarity: Some(0.9),
3714 children: None,
3715 base_changes: None,
3716 }],
3717 );
3718
3719 let diff = build_function_diff(&all_diffs, &project);
3720
3721 assert_eq!(
3722 diff.changed.len(),
3723 1,
3724 "Move with both old/new text should become a changed function"
3725 );
3726 assert_eq!(diff.changed[0].name, "moved_fn");
3727 assert_eq!(diff.changed[0].old_source, "fn moved_fn() { a }");
3728 assert_eq!(diff.changed[0].new_source, "fn moved_fn() { b }");
3729 }
3730
3731 #[test]
3732 fn test_build_function_diff_multiple_files() {
3733 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3734
3735 let project = PathBuf::from("/project");
3736 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3737 all_diffs.insert(
3738 PathBuf::from("/project/src/a.rs"),
3739 vec![ASTChange {
3740 change_type: ChangeType::Insert,
3741 node_kind: NodeKind::Function,
3742 name: Some("fn_a".to_string()),
3743 old_location: None,
3744 new_location: Some(Location::new("src/a.rs", 1)),
3745 old_text: None,
3746 new_text: Some("fn fn_a() {}".to_string()),
3747 similarity: None,
3748 children: None,
3749 base_changes: None,
3750 }],
3751 );
3752 all_diffs.insert(
3753 PathBuf::from("/project/src/b.rs"),
3754 vec![ASTChange {
3755 change_type: ChangeType::Delete,
3756 node_kind: NodeKind::Method,
3757 name: Some("fn_b".to_string()),
3758 old_location: Some(Location::new("src/b.rs", 5)),
3759 new_location: None,
3760 old_text: Some("fn fn_b() {}".to_string()),
3761 new_text: None,
3762 similarity: None,
3763 children: None,
3764 base_changes: None,
3765 }],
3766 );
3767
3768 let diff = build_function_diff(&all_diffs, &project);
3769
3770 assert_eq!(diff.inserted.len(), 1, "Should have insert from a.rs");
3771 assert_eq!(diff.deleted.len(), 1, "Should have delete from b.rs");
3772 assert_eq!(diff.inserted[0].name, "fn_a");
3773 assert_eq!(diff.deleted[0].name, "fn_b");
3774 }
3775
3776 #[test]
3777 fn test_build_function_diff_empty_input() {
3778 let project = PathBuf::from("/project");
3779 let all_diffs: HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>> =
3780 HashMap::new();
3781
3782 let diff = build_function_diff(&all_diffs, &project);
3783
3784 assert_eq!(diff.changed.len(), 0);
3785 assert_eq!(diff.inserted.len(), 0);
3786 assert_eq!(diff.deleted.len(), 0);
3787 }
3788
3789 #[test]
3790 fn test_build_function_diff_path_already_relative() {
3791 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
3792
3793 let project = PathBuf::from("/project");
3796 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3797 all_diffs.insert(
3798 PathBuf::from("src/lib.rs"), vec![ASTChange {
3800 change_type: ChangeType::Insert,
3801 node_kind: NodeKind::Function,
3802 name: Some("f".to_string()),
3803 old_location: None,
3804 new_location: Some(Location::new("src/lib.rs", 1)),
3805 old_text: None,
3806 new_text: Some("fn f() {}".to_string()),
3807 similarity: None,
3808 children: None,
3809 base_changes: None,
3810 }],
3811 );
3812
3813 let diff = build_function_diff(&all_diffs, &project);
3814
3815 assert_eq!(diff.inserted.len(), 1);
3816 assert_eq!(diff.inserted[0].id.file, PathBuf::from("src/lib.rs"));
3818 }
3819
3820 #[test]
3829 fn test_bugbot_finds_real_bugs() {
3830 use crate::commands::bugbot::l2::context::FunctionDiff;
3831 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3832 use std::collections::HashMap;
3833
3834 let simple_src = "def process(x):\n return x + 1\n";
3839 let complex_src = r#"def process(x):
3840 if x > 10:
3841 if x > 20:
3842 if x > 30:
3843 return x * 3
3844 elif x > 25:
3845 return x * 2
3846 else:
3847 return x
3848 elif x > 15:
3849 return x - 1
3850 else:
3851 return x + 1
3852 else:
3853 return 0
3854"#;
3855
3856 let file = PathBuf::from("src/process.py");
3857
3858 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
3859 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
3860 baseline_contents.insert(file.clone(), simple_src.to_string());
3861 current_contents.insert(file.clone(), complex_src.to_string());
3862
3863 let ctx = L2Context::new(
3864 PathBuf::from("/tmp/bugbot-simulation"),
3865 Language::Python,
3866 vec![file],
3867 FunctionDiff {
3868 changed: vec![],
3869 inserted: vec![],
3870 deleted: vec![],
3871 },
3872 baseline_contents,
3873 current_contents,
3874 HashMap::new(),
3875 );
3876
3877 let all_engines = l2_engine_registry();
3881 let (findings, results) = run_l2_engines(&ctx, &all_engines);
3882
3883 let has_finding = |finding_type: &str| -> bool {
3887 findings.iter().any(|f| f.finding_type == finding_type)
3888 };
3889
3890 assert!(
3891 has_finding("complexity-increase"),
3892 "Expected complexity-increase finding. Got: {:?}",
3893 findings
3894 .iter()
3895 .map(|f| format!("{}:{}", f.finding_type, f.function))
3896 .collect::<Vec<_>>()
3897 );
3898
3899 eprintln!("\n=== Bugbot Simulation Results ===");
3903 eprintln!("Total findings: {}", findings.len());
3904 for engine_result in &results {
3905 eprintln!(
3906 " {}: {} findings ({}ms, analyzed={}, skipped={})",
3907 engine_result.name,
3908 engine_result.finding_count,
3909 engine_result.duration_ms,
3910 engine_result.functions_analyzed,
3911 engine_result.functions_skipped,
3912 );
3913 }
3914
3915 assert_eq!(
3921 results.len(),
3922 all_engines.len(),
3923 "Every engine must produce a result"
3924 );
3925
3926 assert!(
3928 !findings.is_empty(),
3929 "Expected at least 1 finding from buggy code, got 0"
3930 );
3931
3932 let total_from_results: usize = results.iter().map(|r| r.finding_count).sum();
3934 assert_eq!(
3935 findings.len(),
3936 total_from_results,
3937 "Merged findings count must equal sum of per-engine finding counts"
3938 );
3939 }
3940}