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