Skip to main content

tldr_cli/commands/bugbot/
check.rs

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