1use std::collections::hash_map::DefaultHasher;
42use std::collections::{BTreeMap, BTreeSet, HashMap};
43use std::hash::{Hash, Hasher};
44use std::path::{Path, PathBuf};
45use std::process::Command;
46use std::time::{Duration, Instant};
47
48use tempfile::TempDir;
49
50use super::super::context::L2Context;
51use super::super::types::{AnalyzerStatus, L2AnalyzerOutput};
52use super::super::L2Engine;
53use crate::commands::bugbot::dead::is_test_function;
54use crate::commands::bugbot::types::BugbotFinding;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum TldrCategory {
59 Local,
61 Flow,
63}
64
65#[derive(Debug, Clone)]
67struct TldrCommand {
68 name: &'static str,
70 args: &'static [&'static str],
72 category: TldrCategory,
74}
75
76const TLDR_COMMANDS: &[TldrCommand] = &[
78 TldrCommand { name: "complexity", args: &["complexity"], category: TldrCategory::Local },
80 TldrCommand { name: "cognitive", args: &["cognitive"], category: TldrCategory::Local },
81 TldrCommand { name: "contracts", args: &["contracts"], category: TldrCategory::Local },
82 TldrCommand { name: "smells", args: &["smells"], category: TldrCategory::Local },
83 TldrCommand { name: "calls", args: &["calls"], category: TldrCategory::Flow },
85 TldrCommand { name: "deps", args: &["deps"], category: TldrCategory::Flow },
86 TldrCommand { name: "coupling", args: &["coupling"], category: TldrCategory::Flow },
87 TldrCommand { name: "cohesion", args: &["cohesion"], category: TldrCategory::Flow },
88 TldrCommand { name: "dead", args: &["dead"], category: TldrCategory::Flow },
89];
90
91const FINDING_TYPES: &[&str] = &[
93 "complexity-increase",
94 "cognitive-increase",
95 "contract-removed",
96 "smell-introduced",
97 "call-graph-change",
98 "dependency-change",
99 "coupling-increase",
100 "cohesion-decrease",
101 "dead-code-introduced",
102 "downstream-impact",
103 "breaking-change-risk",
104];
105
106const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024; pub struct TldrDifferentialEngine {
120 timeout_secs: u64,
122}
123
124impl TldrDifferentialEngine {
125 pub fn new() -> Self {
127 Self { timeout_secs: 30 }
128 }
129
130 pub fn with_timeout(timeout_secs: u64) -> Self {
132 Self { timeout_secs }
133 }
134
135 fn run_tldr_command(
144 &self,
145 args: &[&str],
146 target: &Path,
147 ) -> Result<serde_json::Value, String> {
148 let target_str = target.to_string_lossy().to_string();
149 let mut full_args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
150 full_args.push(target_str);
151 full_args.push("--format".to_string());
152 full_args.push("json".to_string());
153 self.run_tldr_raw(&full_args)
154 }
155
156 fn run_tldr_per_function(
161 &self,
162 command: &str,
163 file: &Path,
164 function_name: &str,
165 ) -> Result<serde_json::Value, String> {
166 let file_str = file.to_string_lossy().to_string();
167 let args = vec![
168 command.to_string(),
169 file_str,
170 function_name.to_string(),
171 "--format".to_string(),
172 "json".to_string(),
173 ];
174 self.run_tldr_raw(&args)
175 }
176
177 fn run_tldr_flow_command(
185 &self,
186 cmd_name: &str,
187 args: &[&str],
188 target: &Path,
189 language: &str,
190 ) -> Result<serde_json::Value, String> {
191 let target_str = target.to_string_lossy().to_string();
192 let mut full_args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
193 full_args.push(target_str);
194 full_args.push("--lang".to_string());
195 full_args.push(language.to_string());
196 if cmd_name == "calls" {
199 full_args.push("--respect-ignore".to_string());
200 }
201 full_args.push("--format".to_string());
202 full_args.push("json".to_string());
203 self.run_tldr_raw(&full_args)
204 }
205
206 fn run_tldr_raw(
208 &self,
209 args: &[String],
210 ) -> Result<serde_json::Value, String> {
211 let child = Command::new("tldr")
212 .args(args)
213 .stdout(std::process::Stdio::piped())
214 .stderr(std::process::Stdio::piped())
215 .spawn();
216
217 let child = match child {
218 Ok(c) => c,
219 Err(e) => return Err(format!("Failed to spawn 'tldr': {}", e)),
220 };
221
222 let timeout = Duration::from_secs(self.timeout_secs);
224 let child_id = child.id();
225 let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
226 let timed_out_clone = timed_out.clone();
227
228 let _watchdog = std::thread::spawn(move || {
229 std::thread::sleep(timeout);
230 timed_out_clone.store(true, std::sync::atomic::Ordering::SeqCst);
231 #[cfg(unix)]
232 unsafe {
233 libc::kill(child_id as libc::pid_t, libc::SIGKILL);
234 }
235 #[cfg(windows)]
236 unsafe {
237 let handle = windows_sys::Win32::System::Threading::OpenProcess(
238 windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
239 0,
240 child_id,
241 );
242 if handle != 0 {
243 windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
244 windows_sys::Win32::Foundation::CloseHandle(handle);
245 }
246 }
247 });
248
249 let output = child
250 .wait_with_output()
251 .map_err(|e| format!("Failed to read tldr output: {}", e))?;
252
253 if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
254 return Err(format!("Timeout after {}s", self.timeout_secs));
255 }
256
257 let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
258 let stdout = if raw_stdout.len() > MAX_OUTPUT_BYTES {
259 let mut truncated = raw_stdout;
260 truncated.truncate(MAX_OUTPUT_BYTES);
261 if let Some(last_newline) = truncated.rfind('\n') {
262 truncated.truncate(last_newline + 1);
263 }
264 truncated
265 } else {
266 raw_stdout
267 };
268
269 if stdout.trim().is_empty() {
270 return Err(format!(
271 "tldr {} produced empty output (exit code: {:?}, stderr: {})",
272 args.first().map(|s| s.as_str()).unwrap_or("?"),
273 output.status.code(),
274 String::from_utf8_lossy(&output.stderr),
275 ));
276 }
277
278 serde_json::from_str(&stdout)
279 .map_err(|e| format!("Failed to parse tldr JSON: {} (first 200 chars: {:?})", e, &stdout[..stdout.len().min(200)]))
280 }
281
282 fn analyze_local_commands(
289 &self,
290 file_path: &Path,
291 baseline_source: &str,
292 current_source: &str,
293 partial_reasons: &mut Vec<String>,
294 ) -> Vec<BugbotFinding> {
295 let mut findings = Vec::new();
296
297 let ext = file_path
298 .extension()
299 .and_then(|e| e.to_str())
300 .unwrap_or("py");
301
302 let tmp_dir = match TempDir::new() {
304 Ok(d) => d,
305 Err(e) => {
306 partial_reasons.push(format!("tmpdir creation failed: {}", e));
307 return findings;
308 }
309 };
310
311 let baseline_file = tmp_dir.path().join(format!("baseline.{}", ext));
312 let current_file = tmp_dir.path().join(format!("current.{}", ext));
313
314 if std::fs::write(&baseline_file, baseline_source).is_err() {
315 partial_reasons.push(format!("write baseline tmpfile failed for {}", file_path.display()));
316 return findings;
317 }
318 if std::fs::write(¤t_file, current_source).is_err() {
319 partial_reasons.push(format!("write current tmpfile failed for {}", file_path.display()));
320 return findings;
321 }
322
323 for cmd_name in &["cognitive", "smells"] {
326 let baseline_result = self.run_tldr_command(&[cmd_name], &baseline_file);
327 let current_result = self.run_tldr_command(&[cmd_name], ¤t_file);
328
329 match (baseline_result, current_result) {
330 (Ok(baseline_json), Ok(current_json)) => {
331 let cmd_findings = self.diff_local_metrics(
332 cmd_name,
333 file_path,
334 &baseline_json,
335 ¤t_json,
336 );
337 findings.extend(cmd_findings);
338 }
339 (Err(e), _) | (_, Err(e)) => {
340 partial_reasons.push(format!(
341 "tldr {} failed for {}: {}",
342 cmd_name,
343 file_path.display(),
344 e,
345 ));
346 }
347 }
348 }
349
350 let baseline_funcs = Self::discover_function_names_from_cognitive(
353 &self.run_tldr_command(&["cognitive"], &baseline_file),
354 );
355 let current_funcs = Self::discover_function_names_from_cognitive(
356 &self.run_tldr_command(&["cognitive"], ¤t_file),
357 );
358
359 {
361 let mut baseline_entries: Vec<(String, serde_json::Value)> = Vec::new();
362 for func in &baseline_funcs {
363 match self.run_tldr_per_function("complexity", &baseline_file, func) {
364 Ok(json) => baseline_entries.push((func.clone(), json)),
365 Err(e) => {
366 partial_reasons.push(format!("tldr complexity {} baseline: {}", func, e));
367 }
368 }
369 }
370
371 let mut current_entries: Vec<(String, serde_json::Value)> = Vec::new();
372 for func in ¤t_funcs {
373 match self.run_tldr_per_function("complexity", ¤t_file, func) {
374 Ok(json) => current_entries.push((func.clone(), json)),
375 Err(e) => {
376 partial_reasons.push(format!("tldr complexity {} current: {}", func, e));
377 }
378 }
379 }
380
381 let baseline_agg = Self::aggregate_per_function_complexity(&baseline_entries);
384 let current_agg = Self::aggregate_per_function_complexity(¤t_entries);
385
386 let complexity_findings = self.diff_local_metrics(
387 "complexity",
388 file_path,
389 &baseline_agg,
390 ¤t_agg,
391 );
392 findings.extend(complexity_findings);
393 }
394
395 {
397 let mut baseline_entries: Vec<(String, serde_json::Value)> = Vec::new();
398 for func in &baseline_funcs {
399 match self.run_tldr_per_function("contracts", &baseline_file, func) {
400 Ok(json) => baseline_entries.push((func.clone(), json)),
401 Err(e) => {
402 partial_reasons.push(format!("tldr contracts {} baseline: {}", func, e));
403 }
404 }
405 }
406
407 let current_func_set: std::collections::HashSet<&str> =
413 current_funcs.iter().map(|s| s.as_str()).collect();
414 let all_current_candidates: Vec<String> = current_funcs
415 .iter()
416 .cloned()
417 .chain(
418 baseline_funcs
419 .iter()
420 .filter(|f| !current_func_set.contains(f.as_str()))
421 .cloned(),
422 )
423 .collect();
424
425 let mut current_entries: Vec<(String, serde_json::Value)> = Vec::new();
426 for func in &all_current_candidates {
427 match self.run_tldr_per_function("contracts", ¤t_file, func) {
428 Ok(json) => current_entries.push((func.clone(), json)),
429 Err(e) => {
430 partial_reasons.push(format!("tldr contracts {} current: {}", func, e));
431 }
432 }
433 }
434
435 let baseline_agg = Self::aggregate_per_function_contracts(&baseline_entries);
436 let current_agg = Self::aggregate_per_function_contracts(¤t_entries);
437
438 let contract_findings = self.diff_contracts(
439 file_path,
440 &baseline_agg,
441 ¤t_agg,
442 &all_current_candidates,
443 );
444 findings.extend(contract_findings);
445 }
446
447 findings
448 }
449
450 fn discover_function_names_from_cognitive(
455 result: &Result<serde_json::Value, String>,
456 ) -> Vec<String> {
457 match result {
458 Ok(json) => {
459 Self::extract_function_entries(json)
460 .into_iter()
461 .map(|(name, _)| name)
462 .filter(|name| !is_test_function(name))
463 .collect()
464 }
465 Err(_) => Vec::new(),
466 }
467 }
468
469 fn aggregate_per_function_complexity(entries: &[(String, serde_json::Value)]) -> serde_json::Value {
474 let functions: Vec<serde_json::Value> = entries
475 .iter()
476 .map(|(name, json)| {
477 let cyclomatic = json.get("cyclomatic").and_then(|v| v.as_f64()).unwrap_or(0.0);
478 let line = json.get("lines_of_code").and_then(|v| v.as_u64()).unwrap_or(1);
479 serde_json::json!({
480 "name": name,
481 "cyclomatic": cyclomatic,
482 "line": line,
483 })
484 })
485 .collect();
486 serde_json::json!({ "functions": functions })
487 }
488
489 fn aggregate_per_function_contracts(entries: &[(String, serde_json::Value)]) -> serde_json::Value {
493 let functions: Vec<serde_json::Value> = entries
494 .iter()
495 .map(|(name, json)| {
496 let preconditions = json.get("preconditions").cloned().unwrap_or(serde_json::json!([]));
497 let postconditions = json.get("postconditions").cloned().unwrap_or(serde_json::json!([]));
498 serde_json::json!({
499 "name": name,
500 "preconditions": preconditions,
501 "postconditions": postconditions,
502 })
503 })
504 .collect();
505 serde_json::json!({ "functions": functions })
506 }
507
508 fn diff_local_metrics(
514 &self,
515 command_name: &str,
516 file_path: &Path,
517 baseline_json: &serde_json::Value,
518 current_json: &serde_json::Value,
519 ) -> Vec<BugbotFinding> {
520 let mut findings = Vec::new();
521
522 match command_name {
523 "complexity" => {
524 findings.extend(self.diff_numeric_metrics(
525 "complexity-increase",
526 "cyclomatic",
527 file_path,
528 baseline_json,
529 current_json,
530 ));
531 }
532 "cognitive" => {
533 findings.extend(self.diff_numeric_metrics(
534 "cognitive-increase",
535 "cognitive",
536 file_path,
537 baseline_json,
538 current_json,
539 ));
540 }
541 "contracts" => {
542 findings.extend(self.diff_contracts(
547 file_path,
548 baseline_json,
549 current_json,
550 &[],
551 ));
552 }
553 "smells" => {
554 findings.extend(self.diff_smells(
555 file_path,
556 baseline_json,
557 current_json,
558 ));
559 }
560 _ => {}
561 }
562
563 findings
564 }
565
566 fn extract_function_entries(json: &serde_json::Value) -> Vec<(String, &serde_json::Value)> {
571 let mut entries = Vec::new();
572
573 for key in &["functions", "results", "items", "entries", "metrics"] {
575 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
576 for item in arr {
577 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
578 entries.push((name.to_string(), item));
579 }
580 }
581 if !entries.is_empty() {
582 return entries;
583 }
584 }
585 }
586
587 if let Some(arr) = json.as_array() {
589 for item in arr {
590 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
591 entries.push((name.to_string(), item));
592 }
593 }
594 }
595
596 entries
597 }
598
599 fn diff_numeric_metrics(
604 &self,
605 finding_type: &str,
606 metric_field: &str,
607 file_path: &Path,
608 baseline_json: &serde_json::Value,
609 current_json: &serde_json::Value,
610 ) -> Vec<BugbotFinding> {
611 let mut findings = Vec::new();
612
613 let baseline_entries = Self::extract_function_entries(baseline_json);
614 let current_entries = Self::extract_function_entries(current_json);
615
616 let baseline_map: std::collections::HashMap<&str, &serde_json::Value> = baseline_entries
617 .iter()
618 .map(|(name, val)| (name.as_str(), *val))
619 .collect();
620
621 for (func_name, current_entry) in ¤t_entries {
622 let Some(baseline_entry) = baseline_map.get(func_name.as_str()) else {
623 if let Some(current_val) = current_entry.get(metric_field).and_then(|v| v.as_f64()) {
625 if current_val > 10.0 {
626 findings.push(BugbotFinding {
627 finding_type: finding_type.to_string(),
628 severity: "info".to_string(),
629 file: file_path.to_path_buf(),
630 function: func_name.clone(),
631 line: current_entry.get("line").and_then(|l| l.as_u64()).unwrap_or(1) as usize,
632 message: format!(
633 "New function `{}` has {} = {:.1}",
634 func_name, metric_field, current_val,
635 ),
636 evidence: serde_json::json!({
637 "command": finding_type.replace("-increase", ""),
638 "metric": metric_field,
639 "current_value": current_val,
640 "new_function": true,
641 }),
642 confidence: Some("DETERMINISTIC".to_string()),
643 finding_id: Some(compute_finding_id(finding_type, file_path, func_name, 0)),
644 });
645 }
646 }
647 continue;
648 };
649
650 let baseline_val = baseline_entry.get(metric_field).and_then(|v| v.as_f64()).unwrap_or(0.0);
651 let current_val = current_entry.get(metric_field).and_then(|v| v.as_f64()).unwrap_or(0.0);
652
653 if current_val > baseline_val {
654 let delta = current_val - baseline_val;
655
656 let min_delta = match finding_type {
661 "cognitive-increase" => 3.0,
662 "complexity-increase" => 2.0,
663 _ => 1.0,
664 };
665 if delta < min_delta {
666 continue;
667 }
668
669 let pct_increase = if baseline_val > 0.0 {
670 (delta / baseline_val) * 100.0
671 } else {
672 100.0
673 };
674
675 let severity = if pct_increase > 50.0 {
676 "high"
677 } else if pct_increase > 20.0 {
678 "medium"
679 } else {
680 "low"
681 };
682
683 let line = current_entry.get("line").and_then(|l| l.as_u64()).unwrap_or(1) as usize;
684
685 findings.push(BugbotFinding {
686 finding_type: finding_type.to_string(),
687 severity: severity.to_string(),
688 file: file_path.to_path_buf(),
689 function: func_name.clone(),
690 line,
691 message: format!(
692 "`{}` {} increased by {:.1} ({:.1} -> {:.1}, +{:.0}%)",
693 func_name, metric_field, delta, baseline_val, current_val, pct_increase,
694 ),
695 evidence: serde_json::json!({
696 "command": finding_type.replace("-increase", ""),
697 "metric": metric_field,
698 "old_value": baseline_val,
699 "new_value": current_val,
700 "delta": delta,
701 "pct_increase": pct_increase,
702 }),
703 confidence: Some("DETERMINISTIC".to_string()),
704 finding_id: Some(compute_finding_id(finding_type, file_path, func_name, line)),
705 });
706 }
707 }
708
709 findings
710 }
711
712 fn diff_contracts(
722 &self,
723 file_path: &Path,
724 baseline_json: &serde_json::Value,
725 current_json: &serde_json::Value,
726 known_current_funcs: &[String],
727 ) -> Vec<BugbotFinding> {
728 let mut findings = Vec::new();
729
730 let baseline_entries = Self::extract_function_entries(baseline_json);
731 let current_entries = Self::extract_function_entries(current_json);
732
733 let current_names: std::collections::HashSet<String> = current_entries
734 .iter()
735 .map(|(name, _)| name.clone())
736 .collect();
737
738 let baseline_contract_count = |entry: &serde_json::Value| -> usize {
740 let pre = entry.get("preconditions").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
741 let post = entry.get("postconditions").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
742 pre + post
743 };
744
745 let current_map: std::collections::HashMap<&str, &serde_json::Value> = current_entries
746 .iter()
747 .map(|(name, val)| (name.as_str(), *val))
748 .collect();
749
750 for (func_name, baseline_entry) in &baseline_entries {
751 let b_count = baseline_contract_count(baseline_entry);
752 if b_count == 0 {
753 continue;
754 }
755
756 if let Some(current_entry) = current_map.get(func_name.as_str()) {
757 let c_count = baseline_contract_count(current_entry);
758 if c_count < b_count {
759 let removed = b_count - c_count;
760 findings.push(BugbotFinding {
761 finding_type: "contract-removed".to_string(),
762 severity: "medium".to_string(),
763 file: file_path.to_path_buf(),
764 function: func_name.clone(),
765 line: 1,
766 message: format!(
767 "`{}` lost {} contract(s) ({} -> {})",
768 func_name, removed, b_count, c_count,
769 ),
770 evidence: serde_json::json!({
771 "command": "contracts",
772 "baseline_contracts": b_count,
773 "current_contracts": c_count,
774 "removed": removed,
775 }),
776 confidence: Some("DETERMINISTIC".to_string()),
777 finding_id: Some(compute_finding_id("contract-removed", file_path, func_name, 1)),
778 });
779 }
780 } else if !current_names.contains(func_name.as_str()) {
781 if known_current_funcs.iter().any(|f| f == func_name) {
785 continue;
786 }
787 findings.push(BugbotFinding {
789 finding_type: "contract-removed".to_string(),
790 severity: "high".to_string(),
791 file: file_path.to_path_buf(),
792 function: func_name.clone(),
793 line: 1,
794 message: format!(
795 "`{}` with {} contract(s) was removed entirely",
796 func_name, b_count,
797 ),
798 evidence: serde_json::json!({
799 "command": "contracts",
800 "baseline_contracts": b_count,
801 "current_contracts": 0,
802 "function_deleted": true,
803 }),
804 confidence: Some("DETERMINISTIC".to_string()),
805 finding_id: Some(compute_finding_id("contract-removed", file_path, func_name, 0)),
806 });
807 }
808 }
809
810 findings
811 }
812
813 fn diff_smells(
818 &self,
819 file_path: &Path,
820 baseline_json: &serde_json::Value,
821 current_json: &serde_json::Value,
822 ) -> Vec<BugbotFinding> {
823 let mut findings = Vec::new();
824
825 let count_smells = |json: &serde_json::Value| -> usize {
826 for key in &["smells", "issues", "findings", "results"] {
828 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
829 return arr.len();
830 }
831 }
832 if let Some(arr) = json.as_array() {
833 return arr.len();
834 }
835 0
836 };
837
838 let baseline_count = count_smells(baseline_json);
839 let current_count = count_smells(current_json);
840
841 if baseline_count == 0 {
843 return findings;
844 }
845
846 if current_count > baseline_count {
847 let introduced = current_count - baseline_count;
848
849 let current_smells: Vec<&serde_json::Value> = {
851 let mut result = Vec::new();
852 for key in &["smells", "issues", "findings", "results"] {
853 if let Some(arr) = current_json.get(key).and_then(|v| v.as_array()) {
854 result = arr.iter().collect();
855 break;
856 }
857 }
858 if result.is_empty() {
859 if let Some(arr) = current_json.as_array() {
860 result = arr.iter().collect();
861 }
862 }
863 result
864 };
865
866 const SUPPRESSED_SMELL_TYPES: &[&str] = &[
870 "message_chain",
871 "long_parameter_list",
872 ];
873
874 for (i, smell) in current_smells.iter().rev().take(introduced).enumerate() {
876 let smell_type = smell.get("smell_type").or_else(|| smell.get("type")).or_else(|| smell.get("kind")).and_then(|v| v.as_str()).unwrap_or("unknown");
877
878 if SUPPRESSED_SMELL_TYPES.contains(&smell_type) {
879 continue;
880 }
881
882 let func_name = smell.get("function").or_else(|| smell.get("name")).and_then(|v| v.as_str()).unwrap_or("(file-level)");
883 let line = smell.get("line").and_then(|l| l.as_u64()).unwrap_or(1) as usize;
884
885 let severity = match smell_type {
888 "god_class" | "feature_envy" | "data_clump" => "medium",
889 _ => "low",
890 };
891
892 findings.push(BugbotFinding {
893 finding_type: "smell-introduced".to_string(),
894 severity: severity.to_string(),
895 file: file_path.to_path_buf(),
896 function: func_name.to_string(),
897 line,
898 message: format!(
899 "New code smell `{}` introduced (total smells: {} -> {})",
900 smell_type, baseline_count, current_count,
901 ),
902 evidence: serde_json::json!({
903 "command": "smells",
904 "smell_type": smell_type,
905 "baseline_smell_count": baseline_count,
906 "current_smell_count": current_count,
907 "introduced": introduced,
908 "index": i,
909 }),
910 confidence: Some("DETERMINISTIC".to_string()),
911 finding_id: Some(compute_finding_id("smell-introduced", file_path, func_name, line)),
912 });
913 }
914 }
915
916 findings
917 }
918
919 fn analyze_flow_commands(
933 &self,
934 project: &Path,
935 base_ref: &str,
936 language: &str,
937 current_calls_json: Option<&serde_json::Value>,
938 partial_reasons: &mut Vec<String>,
939 ) -> Vec<BugbotFinding> {
940 let mut findings = Vec::new();
941
942 let flow_engine = TldrDifferentialEngine::with_timeout(300);
946
947 for cmd in TLDR_COMMANDS.iter().filter(|c| c.category == TldrCategory::Flow && c.name == "dead") {
949 match flow_engine.run_tldr_flow_command(cmd.name, cmd.args, project, language) {
950 Ok(json) => {
951 let dead_count = Self::count_dead_code_entries(&json);
952 if dead_count > 0 {
953 findings.push(BugbotFinding {
954 finding_type: "dead-code-introduced".to_string(),
955 severity: "info".to_string(),
956 file: PathBuf::from("(project)"),
957 function: "(project-level)".to_string(),
958 line: 0,
959 message: format!(
960 "{} dead code entries detected in project",
961 dead_count,
962 ),
963 evidence: serde_json::json!({
964 "command": cmd.name,
965 "dead_code_count": dead_count,
966 }),
967 confidence: Some("DETERMINISTIC".to_string()),
968 finding_id: Some(compute_finding_id(
969 "dead-code-introduced",
970 Path::new("(project)"),
971 "(project-level)",
972 0,
973 )),
974 });
975 }
976 }
977 Err(e) => {
978 partial_reasons.push(format!("tldr {} failed: {}", cmd.name, e));
979 }
980 }
981 }
982
983 use crate::commands::bugbot::first_run::{
989 load_cached_baseline_call_graph, resolve_git_ref, save_baseline_call_graph,
990 };
991
992 let base_commit = resolve_git_ref(project, base_ref).ok();
993 let cached_baseline = base_commit
994 .as_deref()
995 .and_then(|hash| load_cached_baseline_call_graph(project, hash));
996
997 let mut calls_deps_done = false;
999
1000 if let Some(ref cached_cg) = cached_baseline {
1001 let current_calls_result: Result<std::borrow::Cow<'_, serde_json::Value>, String> =
1003 if let Some(cached) = current_calls_json {
1004 Ok(std::borrow::Cow::Borrowed(cached))
1005 } else {
1006 flow_engine
1007 .run_tldr_flow_command("calls", &["calls"], project, language)
1008 .map(std::borrow::Cow::Owned)
1009 };
1010
1011 match ¤t_calls_result {
1012 Ok(current_json) => {
1013 findings.extend(self.diff_calls_json(cached_cg, current_json.as_ref()));
1014
1015 let baseline_deps = Self::derive_deps_from_calls(cached_cg);
1016 let current_deps = Self::derive_deps_from_calls(current_json.as_ref());
1017 findings.extend(self.diff_deps_json(&baseline_deps, ¤t_deps));
1018 calls_deps_done = true;
1019 }
1020 Err(e) => {
1021 partial_reasons.push(format!("tldr calls (current) failed: {}", e));
1022 calls_deps_done = true; }
1024 }
1025 }
1026
1027 let needs_worktree = true; if needs_worktree {
1034 let baseline_dir = match tempfile::tempdir() {
1035 Ok(d) => d,
1036 Err(e) => {
1037 partial_reasons.push(format!("tmpdir for baseline worktree: {}", e));
1038 return findings;
1039 }
1040 };
1041 let worktree_path = baseline_dir.path().join("baseline");
1042
1043 let worktree_ok = match Command::new("git")
1044 .args(["worktree", "add", &worktree_path.to_string_lossy(), base_ref])
1045 .current_dir(project)
1046 .stdout(std::process::Stdio::null())
1047 .stderr(std::process::Stdio::piped())
1048 .status()
1049 {
1050 Ok(status) if status.success() => true,
1051 Ok(status) => {
1052 partial_reasons.push(format!(
1053 "git worktree add failed (exit {}); skipping baseline flow diff",
1054 status
1055 ));
1056 false
1057 }
1058 Err(e) => {
1059 partial_reasons.push(format!("git worktree add: {}; skipping baseline flow diff", e));
1060 false
1061 }
1062 };
1063
1064 if worktree_ok {
1065 let tldrignore_src = project.join(".tldrignore");
1068 if tldrignore_src.exists() {
1069 let _ = std::fs::copy(&tldrignore_src, worktree_path.join(".tldrignore"));
1070 }
1071
1072 if !calls_deps_done {
1074 let baseline_calls = flow_engine.run_tldr_flow_command("calls", &["calls"], &worktree_path, language);
1075 let current_calls_result: Result<std::borrow::Cow<'_, serde_json::Value>, String> =
1076 if let Some(cached) = current_calls_json {
1077 Ok(std::borrow::Cow::Borrowed(cached))
1078 } else {
1079 flow_engine
1080 .run_tldr_flow_command("calls", &["calls"], project, language)
1081 .map(std::borrow::Cow::Owned)
1082 };
1083
1084 match (&baseline_calls, ¤t_calls_result) {
1085 (Ok(baseline_json), Ok(current_json)) => {
1086 findings.extend(self.diff_calls_json(baseline_json, current_json.as_ref()));
1088
1089 let baseline_deps = Self::derive_deps_from_calls(baseline_json);
1091 let current_deps = Self::derive_deps_from_calls(current_json.as_ref());
1092 findings.extend(self.diff_deps_json(&baseline_deps, ¤t_deps));
1093
1094 if let Some(ref hash) = base_commit {
1096 let _ = save_baseline_call_graph(project, baseline_json, hash, language);
1097 }
1098 }
1099 (Err(e), _) => {
1100 partial_reasons.push(format!("tldr calls (baseline) failed: {}", e));
1101 }
1102 (_, Err(e)) => {
1103 partial_reasons.push(format!("tldr calls (current) failed: {}", e));
1104 }
1105 }
1106 }
1107
1108 for cmd in TLDR_COMMANDS.iter().filter(|c| c.category == TldrCategory::Flow && c.name == "cohesion") {
1110 let baseline_result = flow_engine.run_tldr_flow_command(cmd.name, cmd.args, &worktree_path, language);
1111 let current_result = flow_engine.run_tldr_flow_command(cmd.name, cmd.args, project, language);
1112 match (baseline_result, current_result) {
1113 (Ok(baseline_json), Ok(current_json)) => {
1114 findings.extend(self.diff_cohesion_json(&baseline_json, ¤t_json));
1115 }
1116 (Err(e), _) => {
1117 partial_reasons.push(format!("tldr cohesion (baseline) failed: {}", e));
1118 }
1119 (_, Err(e)) => {
1120 partial_reasons.push(format!("tldr cohesion (current) failed: {}", e));
1121 }
1122 }
1123 }
1124
1125 let _ = Command::new("git")
1127 .args(["worktree", "remove", "--force", &worktree_path.to_string_lossy()])
1128 .current_dir(project)
1129 .stdout(std::process::Stdio::null())
1130 .stderr(std::process::Stdio::null())
1131 .status();
1132 }
1133 }
1134
1135 findings
1136 }
1137
1138 fn parse_whatbreaks_findings(
1146 file_path: &Path,
1147 json: &serde_json::Value,
1148 ) -> Vec<BugbotFinding> {
1149 let mut findings = Vec::new();
1150
1151 let summary = json.get("summary").unwrap_or(json);
1152 let importer_count = summary
1153 .get("importer_count")
1154 .and_then(|v| v.as_u64())
1155 .unwrap_or(0);
1156 let caller_count = summary
1157 .get("direct_caller_count")
1158 .and_then(|v| v.as_u64())
1159 .unwrap_or(0);
1160 let test_count = summary
1161 .get("affected_test_count")
1162 .and_then(|v| v.as_u64())
1163 .unwrap_or(0);
1164
1165 if importer_count > 0 || caller_count > 0 {
1166 let severity = if importer_count > 10 {
1167 "high"
1168 } else if importer_count > 3 {
1169 "medium"
1170 } else {
1171 "low"
1172 };
1173
1174 findings.push(BugbotFinding {
1175 finding_type: "downstream-impact".to_string(),
1176 severity: severity.to_string(),
1177 file: file_path.to_path_buf(),
1178 function: "(file-level)".to_string(),
1179 line: 0,
1180 message: format!(
1181 "Changed file has {} importers, {} direct callers, {} affected tests",
1182 importer_count, caller_count, test_count,
1183 ),
1184 evidence: serde_json::json!({
1185 "command": "whatbreaks",
1186 "importer_count": importer_count,
1187 "direct_caller_count": caller_count,
1188 "affected_test_count": test_count,
1189 }),
1190 confidence: Some("DETERMINISTIC".to_string()),
1191 finding_id: Some(compute_finding_id(
1192 "downstream-impact",
1193 file_path,
1194 "(file-level)",
1195 0,
1196 )),
1197 });
1198 }
1199
1200 findings
1201 }
1202
1203 pub fn parse_impact_findings(
1215 function_name: &str,
1216 json: &serde_json::Value,
1217 ) -> Vec<BugbotFinding> {
1218 let mut findings = Vec::new();
1219
1220 let (caller_count, callers_preview) = if let Some(target) =
1222 json.get("targets").and_then(|t| t.get(function_name))
1223 {
1224 let count = target
1225 .get("caller_count")
1226 .and_then(|v| v.as_u64())
1227 .unwrap_or(0);
1228 let callers: Vec<String> = target
1229 .get("callers")
1230 .and_then(|v| v.as_array())
1231 .map(|arr| {
1232 arr.iter()
1233 .take(5)
1234 .map(|c| {
1235 let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("?");
1236 let func = c.get("function").and_then(|v| v.as_str()).unwrap_or("?");
1237 format!("{}::{}", file, func)
1238 })
1239 .collect()
1240 })
1241 .unwrap_or_default();
1242 (count, callers)
1243 } else {
1244 let count = json
1246 .get("caller_count")
1247 .and_then(|v| v.as_u64())
1248 .unwrap_or(0);
1249 let callers: Vec<String> = json
1250 .get("callers")
1251 .and_then(|v| v.as_array())
1252 .map(|arr| {
1253 arr.iter()
1254 .take(5)
1255 .map(|c| {
1256 let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("?");
1257 let func = c.get("function").and_then(|v| v.as_str()).unwrap_or("?");
1258 format!("{}::{}", file, func)
1259 })
1260 .collect()
1261 })
1262 .unwrap_or_default();
1263 (count, callers)
1264 };
1265
1266 if caller_count > 0 {
1267 let severity = if caller_count > 5 {
1268 "high"
1269 } else if caller_count >= 2 {
1270 "medium"
1271 } else {
1272 "info"
1273 };
1274
1275 findings.push(BugbotFinding {
1276 finding_type: "breaking-change-risk".to_string(),
1277 severity: severity.to_string(),
1278 file: PathBuf::from("(project)"),
1279 function: function_name.to_string(),
1280 line: 0,
1281 message: format!(
1282 "Function `{}` has {} callers that may be affected by changes",
1283 function_name, caller_count,
1284 ),
1285 evidence: serde_json::json!({
1286 "command": "impact",
1287 "caller_count": caller_count,
1288 "callers_preview": callers_preview,
1289 }),
1290 confidence: Some("DETERMINISTIC".to_string()),
1291 finding_id: Some(compute_finding_id(
1292 "breaking-change-risk",
1293 Path::new("(project)"),
1294 function_name,
1295 0,
1296 )),
1297 });
1298 }
1299
1300 findings
1301 }
1302
1303 fn build_reverse_caller_map(
1310 calls_json: &serde_json::Value,
1311 ) -> HashMap<String, Vec<(String, String)>> {
1312 let mut map: HashMap<String, Vec<(String, String)>> = HashMap::new();
1313
1314 if let Some(edges) = calls_json.get("edges").and_then(|v| v.as_array()) {
1315 for edge in edges {
1316 let src_file = edge.get("src_file").and_then(|v| v.as_str());
1317 let src_func = edge.get("src_func").and_then(|v| v.as_str());
1318 let dst_func = edge.get("dst_func").and_then(|v| v.as_str());
1319
1320 if let (Some(sf), Some(sfn), Some(df)) = (src_file, src_func, dst_func) {
1321 map.entry(df.to_string())
1322 .or_default()
1323 .push((sf.to_string(), sfn.to_string()));
1324 }
1325 }
1326 }
1327
1328 map
1329 }
1330
1331 fn parse_impact_findings_from_callgraph(
1345 func_name: &str,
1346 callers: &[(String, String)],
1347 ) -> Vec<BugbotFinding> {
1348 let mut findings = Vec::new();
1349 let caller_count = callers.len();
1350
1351 if caller_count == 0 {
1352 return findings;
1353 }
1354
1355 let severity = if caller_count > 5 {
1356 "high"
1357 } else if caller_count >= 2 {
1358 "medium"
1359 } else {
1360 "info"
1361 };
1362
1363 let callers_preview: Vec<String> = callers
1364 .iter()
1365 .take(5)
1366 .map(|(file, func)| format!("{}::{}", file, func))
1367 .collect();
1368
1369 findings.push(BugbotFinding {
1370 finding_type: "breaking-change-risk".to_string(),
1371 severity: severity.to_string(),
1372 file: PathBuf::from("(project)"),
1373 function: func_name.to_string(),
1374 line: 0,
1375 message: format!(
1376 "Function `{}` has {} callers that may be affected by changes",
1377 func_name, caller_count
1378 ),
1379 evidence: serde_json::json!({
1380 "command": "calls",
1381 "caller_count": caller_count,
1382 "callers_preview": callers_preview,
1383 }),
1384 confidence: Some("DETERMINISTIC".to_string()),
1385 finding_id: Some(compute_finding_id(
1386 "breaking-change-risk",
1387 Path::new("(project)"),
1388 func_name,
1389 0,
1390 )),
1391 });
1392
1393 findings
1394 }
1395
1396 fn analyze_downstream_impact(
1406 &self,
1407 project: &Path,
1408 changed_files: &[PathBuf],
1409 language: &str,
1410 current_calls_json: Option<&serde_json::Value>,
1411 partial_reasons: &mut Vec<String>,
1412 ) -> Vec<BugbotFinding> {
1413 let mut findings = Vec::new();
1414
1415 if let Some(calls_json) = current_calls_json {
1416 let changed_file_strs: Vec<&str> = changed_files
1418 .iter()
1419 .map(|p| p.strip_prefix(project).unwrap_or(p))
1420 .filter_map(|p| p.to_str())
1421 .collect();
1422
1423 let downstream_results =
1424 Self::derive_downstream_from_calls(calls_json, &changed_file_strs);
1425 for (file_str, metrics) in &downstream_results {
1426 let file_path = project.join(file_str);
1427 let wb_json = serde_json::json!({ "summary": metrics });
1428 findings.extend(Self::parse_whatbreaks_findings(&file_path, &wb_json));
1429 }
1430 } else {
1431 let flow_engine = TldrDifferentialEngine::with_timeout(300);
1433
1434 for file_path in changed_files {
1435 let relative = file_path.strip_prefix(project).unwrap_or(file_path);
1436 let rel_str = relative.to_string_lossy().to_string();
1437
1438 let args = vec![
1439 "whatbreaks".to_string(),
1440 rel_str.clone(),
1441 "--type".to_string(),
1442 "file".to_string(),
1443 "--quick".to_string(),
1444 project.to_string_lossy().to_string(),
1445 "--lang".to_string(),
1446 language.to_string(),
1447 "--format".to_string(),
1448 "json".to_string(),
1449 ];
1450
1451 match flow_engine.run_tldr_raw(&args) {
1452 Ok(json) => {
1453 findings.extend(Self::parse_whatbreaks_findings(file_path, &json));
1454 }
1455 Err(e) => {
1456 partial_reasons
1457 .push(format!("tldr whatbreaks {} failed: {}", rel_str, e));
1458 }
1459 }
1460 }
1461 }
1462
1463 findings
1464 }
1465
1466 fn analyze_function_impact(
1480 &self,
1481 project: &Path,
1482 changed_files: &[PathBuf],
1483 language: &str,
1484 current_calls_json: Option<&serde_json::Value>,
1485 partial_reasons: &mut Vec<String>,
1486 ) -> Vec<BugbotFinding> {
1487 let mut findings = Vec::new();
1488 let impact_engine = TldrDifferentialEngine::with_timeout(60);
1489
1490 let mut all_functions: Vec<String> = Vec::new();
1492 for file_path in changed_files {
1493 let relative = file_path.strip_prefix(project).unwrap_or(file_path);
1494 let full_path = project.join(relative);
1495
1496 let cognitive_result =
1497 impact_engine.run_tldr_command(&["cognitive"], &full_path);
1498 let func_names =
1499 Self::discover_function_names_from_cognitive(&cognitive_result);
1500 all_functions.extend(func_names);
1501 }
1502
1503 all_functions.truncate(20);
1505
1506 if all_functions.is_empty() {
1507 return findings;
1508 }
1509
1510 let calls_json_owned: Option<serde_json::Value>;
1512 let calls_json_ref: &serde_json::Value = if let Some(cached) = current_calls_json {
1513 cached
1514 } else {
1515 let args = vec![
1516 "calls".to_string(),
1517 project.to_string_lossy().to_string(),
1518 "--lang".to_string(),
1519 language.to_string(),
1520 "--format".to_string(),
1521 "json".to_string(),
1522 ];
1523
1524 match impact_engine.run_tldr_raw(&args) {
1525 Ok(json) => {
1526 calls_json_owned = Some(json);
1527 calls_json_owned.as_ref().unwrap()
1528 }
1529 Err(e) => {
1530 partial_reasons.push(format!("tldr calls failed: {}", e));
1531 return findings;
1532 }
1533 }
1534 };
1535
1536 let reverse_map = Self::build_reverse_caller_map(calls_json_ref);
1538
1539 for func_name in &all_functions {
1541 let callers = reverse_map.get(func_name).cloned().unwrap_or_default();
1542 findings.extend(Self::parse_impact_findings_from_callgraph(
1543 func_name, &callers,
1544 ));
1545 }
1546
1547 findings
1548 }
1549
1550 fn diff_calls_json(
1562 &self,
1563 baseline: &serde_json::Value,
1564 current: &serde_json::Value,
1565 ) -> Vec<BugbotFinding> {
1566 let mut findings = Vec::new();
1567
1568 let extract_edges = |json: &serde_json::Value| -> std::collections::HashSet<(String, String)> {
1569 let mut set = std::collections::HashSet::new();
1570 if let Some(edges) = json.get("edges").and_then(|v| v.as_array()) {
1571 for edge in edges {
1572 let from = format!(
1573 "{}::{}",
1574 edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("?"),
1575 edge.get("src_func").and_then(|v| v.as_str()).unwrap_or("?"),
1576 );
1577 let to = format!(
1578 "{}::{}",
1579 edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("?"),
1580 edge.get("dst_func").and_then(|v| v.as_str()).unwrap_or("?"),
1581 );
1582 if from != "?::?" && to != "?::?" {
1583 set.insert((from, to));
1584 }
1585 }
1586 }
1587 set
1588 };
1589
1590 let baseline_edges = extract_edges(baseline);
1591 let current_edges = extract_edges(current);
1592
1593 let new_edges: Vec<&(String, String)> = current_edges.difference(&baseline_edges).collect();
1595 let removed_edges: Vec<&(String, String)> = baseline_edges.difference(¤t_edges).collect();
1597
1598 if new_edges.is_empty() && removed_edges.is_empty() {
1599 return findings;
1600 }
1601
1602 for (from, to) in &new_edges {
1604 findings.push(BugbotFinding {
1605 finding_type: "call-graph-change".to_string(),
1606 severity: "info".to_string(),
1607 file: PathBuf::from("(project)"),
1608 function: "(project-level)".to_string(),
1609 line: 0,
1610 message: format!("New call edge: {} -> {}", from, to),
1611 evidence: serde_json::json!({
1612 "change": "added",
1613 "from": from,
1614 "to": to,
1615 }),
1616 confidence: Some("DETERMINISTIC".to_string()),
1617 finding_id: Some(compute_finding_id(
1618 "call-graph-change",
1619 Path::new("(project)"),
1620 &format!("{}:{}", from, to),
1621 0,
1622 )),
1623 });
1624 }
1625
1626 for (from, to) in &removed_edges {
1628 findings.push(BugbotFinding {
1629 finding_type: "call-graph-change".to_string(),
1630 severity: "info".to_string(),
1631 file: PathBuf::from("(project)"),
1632 function: "(project-level)".to_string(),
1633 line: 0,
1634 message: format!("Removed call edge: {} -> {}", from, to),
1635 evidence: serde_json::json!({
1636 "change": "removed",
1637 "from": from,
1638 "to": to,
1639 }),
1640 confidence: Some("DETERMINISTIC".to_string()),
1641 finding_id: Some(compute_finding_id(
1642 "call-graph-change",
1643 Path::new("(project)"),
1644 &format!("removed:{}:{}", from, to),
1645 0,
1646 )),
1647 });
1648 }
1649
1650 if new_edges.len() > 5 {
1652 findings.push(BugbotFinding {
1653 finding_type: "call-graph-change".to_string(),
1654 severity: "medium".to_string(),
1655 file: PathBuf::from("(project)"),
1656 function: "(project-level)".to_string(),
1657 line: 0,
1658 message: format!(
1659 "Significant call graph change: {} new edges, {} removed edges",
1660 new_edges.len(),
1661 removed_edges.len(),
1662 ),
1663 evidence: serde_json::json!({
1664 "new_edge_count": new_edges.len(),
1665 "removed_edge_count": removed_edges.len(),
1666 }),
1667 confidence: Some("DETERMINISTIC".to_string()),
1668 finding_id: Some(compute_finding_id(
1669 "call-graph-change",
1670 Path::new("(project)"),
1671 "(summary)",
1672 0,
1673 )),
1674 });
1675 }
1676
1677 findings
1678 }
1679
1680 fn diff_deps_json(
1695 &self,
1696 baseline: &serde_json::Value,
1697 current: &serde_json::Value,
1698 ) -> Vec<BugbotFinding> {
1699 let mut findings = Vec::new();
1700
1701 let extract_circular = |json: &serde_json::Value| -> std::collections::HashSet<String> {
1704 let mut set = std::collections::HashSet::new();
1705 if let Some(circs) = json.get("circular_dependencies").and_then(|v| v.as_array()) {
1706 for circ in circs {
1707 if let Some(path) = circ.get("path").and_then(|v| v.as_array()) {
1709 let mut names: Vec<String> = path
1710 .iter()
1711 .filter_map(|m| m.as_str().map(|s| s.to_string()))
1712 .collect();
1713 names.sort();
1714 set.insert(names.join(","));
1715 }
1716 }
1717 }
1718 set
1719 };
1720
1721 let baseline_circular = extract_circular(baseline);
1722 let current_circular = extract_circular(current);
1723
1724 let new_circular: Vec<&String> = current_circular.difference(&baseline_circular).collect();
1726 for circ in &new_circular {
1727 findings.push(BugbotFinding {
1728 finding_type: "dependency-change".to_string(),
1729 severity: "high".to_string(),
1730 file: PathBuf::from("(project)"),
1731 function: "(project-level)".to_string(),
1732 line: 0,
1733 message: format!("New circular dependency detected: {}", circ),
1734 evidence: serde_json::json!({
1735 "change": "new_circular",
1736 "modules": circ,
1737 }),
1738 confidence: Some("DETERMINISTIC".to_string()),
1739 finding_id: Some(compute_finding_id(
1740 "dependency-change",
1741 Path::new("(project)"),
1742 &format!("circular:{}", circ),
1743 0,
1744 )),
1745 });
1746 }
1747
1748 let count_internal_deps = |json: &serde_json::Value| -> usize {
1752 if let Some(total) = json.get("stats")
1754 .and_then(|s| s.get("total_internal_deps"))
1755 .and_then(|v| v.as_u64())
1756 {
1757 return total as usize;
1758 }
1759 json.get("internal_dependencies")
1761 .and_then(|v| v.as_object())
1762 .map(|obj| obj.values()
1763 .filter_map(|v| v.as_array())
1764 .map(|a| a.len())
1765 .sum())
1766 .unwrap_or(0)
1767 };
1768
1769 let baseline_dep_count = count_internal_deps(baseline);
1770 let current_dep_count = count_internal_deps(current);
1771
1772 if current_dep_count > baseline_dep_count {
1773 let increase = current_dep_count - baseline_dep_count;
1774 if increase > 5 || (baseline_dep_count > 0 && increase * 100 / baseline_dep_count > 20) {
1776 findings.push(BugbotFinding {
1777 finding_type: "dependency-change".to_string(),
1778 severity: "medium".to_string(),
1779 file: PathBuf::from("(project)"),
1780 function: "(project-level)".to_string(),
1781 line: 0,
1782 message: format!(
1783 "Internal dependency count increased: {} -> {} (+{})",
1784 baseline_dep_count, current_dep_count, increase,
1785 ),
1786 evidence: serde_json::json!({
1787 "change": "dependency_count_increase",
1788 "baseline_count": baseline_dep_count,
1789 "current_count": current_dep_count,
1790 "increase": increase,
1791 }),
1792 confidence: Some("DETERMINISTIC".to_string()),
1793 finding_id: Some(compute_finding_id(
1794 "dependency-change",
1795 Path::new("(project)"),
1796 "(dep-count)",
1797 0,
1798 )),
1799 });
1800 }
1801 }
1802
1803 findings
1804 }
1805
1806 pub fn diff_coupling_json(
1817 &self,
1818 baseline: &serde_json::Value,
1819 current: &serde_json::Value,
1820 ) -> Vec<BugbotFinding> {
1821 let mut findings = Vec::new();
1822
1823 let extract_metrics = |json: &serde_json::Value| -> std::collections::HashMap<String, (f64, f64, f64)> {
1824 let mut map = std::collections::HashMap::new();
1825 if let Some(metrics) = json.get("martin_metrics").and_then(|v| v.as_array()) {
1826 for entry in metrics {
1827 let module = entry.get("module").and_then(|v| v.as_str()).unwrap_or("");
1828 if module.is_empty() {
1829 continue;
1830 }
1831 let ca = entry.get("ca").and_then(|v| v.as_f64()).unwrap_or(0.0);
1832 let ce = entry.get("ce").and_then(|v| v.as_f64()).unwrap_or(0.0);
1833 let instability = entry.get("instability").and_then(|v| v.as_f64()).unwrap_or(0.0);
1834 map.insert(module.to_string(), (ca, ce, instability));
1835 }
1836 }
1837 map
1838 };
1839
1840 let baseline_metrics = extract_metrics(baseline);
1841 let current_metrics = extract_metrics(current);
1842
1843 for (module, (_, curr_ce, curr_instability)) in ¤t_metrics {
1844 if let Some((_, base_ce, base_instability)) = baseline_metrics.get(module) {
1845 let instability_delta = curr_instability - base_instability;
1847 let ce_delta = curr_ce - base_ce;
1848
1849 if instability_delta > 0.05 || ce_delta > 2.0 {
1850 let severity = if instability_delta > 0.3 || ce_delta > 5.0 {
1851 "high"
1852 } else if instability_delta > 0.1 || ce_delta > 3.0 {
1853 "medium"
1854 } else {
1855 "low"
1856 };
1857
1858 findings.push(BugbotFinding {
1859 finding_type: "coupling-increase".to_string(),
1860 severity: severity.to_string(),
1861 file: PathBuf::from("(project)"),
1862 function: "(project-level)".to_string(),
1863 line: 0,
1864 message: format!(
1865 "Module '{}': instability {:.2} -> {:.2} (delta {:.2}), ce {} -> {}",
1866 module, base_instability, curr_instability, instability_delta,
1867 base_ce, curr_ce,
1868 ),
1869 evidence: serde_json::json!({
1870 "module": module,
1871 "baseline_instability": base_instability,
1872 "current_instability": curr_instability,
1873 "instability_delta": instability_delta,
1874 "baseline_ce": base_ce,
1875 "current_ce": curr_ce,
1876 "ce_delta": ce_delta,
1877 }),
1878 confidence: Some("DETERMINISTIC".to_string()),
1879 finding_id: Some(compute_finding_id(
1880 "coupling-increase",
1881 Path::new("(project)"),
1882 module,
1883 0,
1884 )),
1885 });
1886 }
1887 }
1888 }
1889
1890 findings
1891 }
1892
1893 fn diff_cohesion_json(
1904 &self,
1905 baseline: &serde_json::Value,
1906 current: &serde_json::Value,
1907 ) -> Vec<BugbotFinding> {
1908 let mut findings = Vec::new();
1909
1910 let extract_lcom4 = |json: &serde_json::Value| -> std::collections::HashMap<String, f64> {
1911 let mut map = std::collections::HashMap::new();
1912 if let Some(classes) = json.get("classes").and_then(|v| v.as_array()) {
1913 for cls in classes {
1914 let name = cls.get("class_name")
1916 .or_else(|| cls.get("name"))
1917 .and_then(|v| v.as_str())
1918 .unwrap_or("");
1919 if name.is_empty() {
1920 continue;
1921 }
1922 let lcom4 = cls.get("lcom4").and_then(|v| v.as_f64()).unwrap_or(0.0);
1923 map.insert(name.to_string(), lcom4);
1924 }
1925 }
1926 map
1927 };
1928
1929 let baseline_lcom = extract_lcom4(baseline);
1930 let current_lcom = extract_lcom4(current);
1931
1932 for (class_name, curr_lcom4) in ¤t_lcom {
1933 if let Some(base_lcom4) = baseline_lcom.get(class_name) {
1934 let delta = curr_lcom4 - base_lcom4;
1936 if delta > 0.5 {
1937 let severity = if delta > 3.0 {
1938 "high"
1939 } else if delta > 1.0 {
1940 "medium"
1941 } else {
1942 "low"
1943 };
1944
1945 findings.push(BugbotFinding {
1946 finding_type: "cohesion-decrease".to_string(),
1947 severity: severity.to_string(),
1948 file: PathBuf::from("(project)"),
1949 function: "(project-level)".to_string(),
1950 line: 0,
1951 message: format!(
1952 "Class '{}': LCOM4 increased {} -> {} (less cohesive)",
1953 class_name, base_lcom4, curr_lcom4,
1954 ),
1955 evidence: serde_json::json!({
1956 "class": class_name,
1957 "baseline_lcom4": base_lcom4,
1958 "current_lcom4": curr_lcom4,
1959 "delta": delta,
1960 }),
1961 confidence: Some("DETERMINISTIC".to_string()),
1962 finding_id: Some(compute_finding_id(
1963 "cohesion-decrease",
1964 Path::new("(project)"),
1965 class_name,
1966 0,
1967 )),
1968 });
1969 }
1970 } else {
1971 if *curr_lcom4 > 3.0 {
1973 findings.push(BugbotFinding {
1974 finding_type: "cohesion-decrease".to_string(),
1975 severity: "info".to_string(),
1976 file: PathBuf::from("(project)"),
1977 function: "(project-level)".to_string(),
1978 line: 0,
1979 message: format!(
1980 "New class '{}' has high LCOM4 ({}): consider splitting",
1981 class_name, curr_lcom4,
1982 ),
1983 evidence: serde_json::json!({
1984 "class": class_name,
1985 "lcom4": curr_lcom4,
1986 "new_class": true,
1987 }),
1988 confidence: Some("DETERMINISTIC".to_string()),
1989 finding_id: Some(compute_finding_id(
1990 "cohesion-decrease",
1991 Path::new("(project)"),
1992 class_name,
1993 0,
1994 )),
1995 });
1996 }
1997 }
1998 }
1999
2000 findings
2001 }
2002
2003 fn count_dead_code_entries(json: &serde_json::Value) -> usize {
2008 if let Some(total) = json.get("total_count").and_then(|v| v.as_u64()) {
2010 return total as usize;
2011 }
2012 for key in &["dead_functions", "possibly_dead", "dead_code", "unreachable", "functions", "results"] {
2014 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
2015 return arr.len();
2016 }
2017 }
2018 if let Some(arr) = json.as_array() {
2019 return arr.len();
2020 }
2021 0
2022 }
2023
2024 pub fn derive_deps_from_calls(calls_json: &serde_json::Value) -> serde_json::Value {
2033 let empty_edges: Vec<serde_json::Value> = Vec::new();
2034 let edges = calls_json
2035 .get("edges")
2036 .and_then(|v| v.as_array())
2037 .unwrap_or(&empty_edges);
2038
2039 let mut dep_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2041 for edge in edges {
2042 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2043 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2044 if src_file.is_empty() || dst_file.is_empty() || src_file == dst_file {
2046 continue;
2047 }
2048 dep_map
2049 .entry(src_file.to_string())
2050 .or_default()
2051 .insert(dst_file.to_string());
2052 }
2053
2054 let total_internal_deps: usize = dep_map.values().map(|s| s.len()).sum();
2056
2057 let mut circular: Vec<serde_json::Value> = Vec::new();
2059 let mut seen_cycles: BTreeSet<(String, String)> = BTreeSet::new();
2060 for (src, destinations) in &dep_map {
2061 for dst in destinations {
2062 if let Some(reverse_deps) = dep_map.get(dst) {
2063 if reverse_deps.contains(src) {
2064 let (a, b) = if src < dst {
2065 (src.clone(), dst.clone())
2066 } else {
2067 (dst.clone(), src.clone())
2068 };
2069 if seen_cycles.insert((a.clone(), b.clone())) {
2070 circular.push(serde_json::json!({
2071 "path": [a, b]
2072 }));
2073 }
2074 }
2075 }
2076 }
2077 }
2078
2079 let internal_deps: serde_json::Map<String, serde_json::Value> = dep_map
2081 .into_iter()
2082 .map(|(k, v)| {
2083 let arr: Vec<serde_json::Value> = v
2084 .into_iter()
2085 .map(serde_json::Value::String)
2086 .collect();
2087 (k, serde_json::Value::Array(arr))
2088 })
2089 .collect();
2090
2091 serde_json::json!({
2092 "internal_dependencies": internal_deps,
2093 "circular_dependencies": circular,
2094 "stats": {
2095 "total_internal_deps": total_internal_deps
2096 }
2097 })
2098 }
2099
2100 pub fn derive_coupling_from_calls(calls_json: &serde_json::Value) -> serde_json::Value {
2107 let empty_edges: Vec<serde_json::Value> = Vec::new();
2108 let edges = calls_json
2109 .get("edges")
2110 .and_then(|v| v.as_array())
2111 .unwrap_or(&empty_edges);
2112
2113 let mut ce_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2115 let mut ca_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2117
2118 for edge in edges {
2119 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2120 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2121 if src_file.is_empty() || dst_file.is_empty() || src_file == dst_file {
2123 continue;
2124 }
2125 ce_map
2126 .entry(src_file.to_string())
2127 .or_default()
2128 .insert(dst_file.to_string());
2129 ca_map
2130 .entry(dst_file.to_string())
2131 .or_default()
2132 .insert(src_file.to_string());
2133 }
2134
2135 let mut all_modules: BTreeSet<String> = BTreeSet::new();
2137 for k in ce_map.keys() {
2138 all_modules.insert(k.clone());
2139 }
2140 for k in ca_map.keys() {
2141 all_modules.insert(k.clone());
2142 }
2143
2144 let mut metrics: Vec<serde_json::Value> = Vec::new();
2145 for module in &all_modules {
2146 let ca = ca_map.get(module).map_or(0, |s| s.len());
2147 let ce = ce_map.get(module).map_or(0, |s| s.len());
2148 let instability = if ca + ce == 0 {
2149 0.0
2150 } else {
2151 ce as f64 / (ca + ce) as f64
2152 };
2153 metrics.push(serde_json::json!({
2154 "module": module,
2155 "ca": ca,
2156 "ce": ce,
2157 "instability": instability
2158 }));
2159 }
2160
2161 serde_json::json!({
2162 "martin_metrics": metrics
2163 })
2164 }
2165
2166 pub fn derive_downstream_from_calls(
2173 calls_json: &serde_json::Value,
2174 changed_files: &[&str],
2175 ) -> Vec<(String, serde_json::Value)> {
2176 let empty_edges: Vec<serde_json::Value> = Vec::new();
2177 let edges = calls_json
2178 .get("edges")
2179 .and_then(|v| v.as_array())
2180 .unwrap_or(&empty_edges);
2181
2182 let mut results: Vec<(String, serde_json::Value)> = Vec::new();
2183
2184 for &changed_file in changed_files {
2185 let mut importers: BTreeSet<String> = BTreeSet::new();
2186 let mut test_importers: BTreeSet<String> = BTreeSet::new();
2187
2188 for edge in edges {
2189 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2190 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2191
2192 if dst_file == changed_file && src_file != changed_file && !src_file.is_empty() {
2194 importers.insert(src_file.to_string());
2195 if src_file.contains("test") {
2196 test_importers.insert(src_file.to_string());
2197 }
2198 }
2199 }
2200
2201 let importer_count = importers.len() as u64;
2202 let affected_test_count = test_importers.len() as u64;
2203
2204 results.push((
2205 changed_file.to_string(),
2206 serde_json::json!({
2207 "importer_count": importer_count,
2208 "direct_caller_count": importer_count,
2209 "affected_test_count": affected_test_count
2210 }),
2211 ));
2212 }
2213
2214 results
2215 }
2216}
2217
2218fn compute_finding_id(finding_type: &str, file: &Path, function: &str, line: usize) -> String {
2223 let mut hasher = DefaultHasher::new();
2224 finding_type.hash(&mut hasher);
2225 file.to_string_lossy().as_ref().hash(&mut hasher);
2226 function.hash(&mut hasher);
2227 line.hash(&mut hasher);
2228 format!("{:x}", hasher.finish())
2229}
2230
2231impl Default for TldrDifferentialEngine {
2232 fn default() -> Self {
2233 Self::new()
2234 }
2235}
2236
2237impl L2Engine for TldrDifferentialEngine {
2238 fn name(&self) -> &'static str {
2239 "TldrDifferentialEngine"
2240 }
2241
2242 fn finding_types(&self) -> &[&'static str] {
2243 FINDING_TYPES
2244 }
2245
2246 fn analyze(&self, ctx: &L2Context) -> L2AnalyzerOutput {
2247 let start = Instant::now();
2248 let mut all_findings = Vec::new();
2249 let mut partial_reasons = Vec::new();
2250
2251 let work_items: Vec<_> = ctx
2253 .changed_files
2254 .iter()
2255 .filter_map(|file_path| {
2256 let baseline = ctx.baseline_contents.get(file_path)?;
2257 let current = ctx.current_contents.get(file_path)?;
2258 Some((file_path, baseline.as_str(), current.as_str()))
2259 })
2260 .collect();
2261
2262 let functions_skipped = ctx.changed_files.len() - work_items.len();
2263 let functions_analyzed = work_items.len();
2264
2265 let num_threads = std::thread::available_parallelism()
2266 .map(|n| n.get())
2267 .unwrap_or(1)
2268 .min(work_items.len().max(1));
2269
2270 if num_threads <= 1 || work_items.len() <= 1 {
2271 for (file_path, baseline_src, current_src) in &work_items {
2272 let mut file_reasons = Vec::new();
2273 let file_findings =
2274 self.analyze_local_commands(file_path, baseline_src, current_src, &mut file_reasons);
2275 all_findings.extend(file_findings);
2276 partial_reasons.extend(file_reasons);
2277 }
2278 } else {
2279 let chunk_size = work_items.len().div_ceil(num_threads);
2280 std::thread::scope(|s| {
2281 let handles: Vec<_> = work_items
2282 .chunks(chunk_size)
2283 .map(|chunk| {
2284 s.spawn(move || {
2285 let mut findings = Vec::new();
2286 let mut reasons = Vec::new();
2287 for (file_path, baseline_src, current_src) in chunk {
2288 let file_findings = self.analyze_local_commands(
2289 file_path,
2290 baseline_src,
2291 current_src,
2292 &mut reasons,
2293 );
2294 findings.extend(file_findings);
2295 }
2296 (findings, reasons)
2297 })
2298 })
2299 .collect();
2300
2301 for handle in handles {
2302 if let Ok((findings, reasons)) = handle.join() {
2303 all_findings.extend(findings);
2304 partial_reasons.extend(reasons);
2305 }
2306 }
2307 });
2308 }
2309
2310 let language_str = ctx.language.as_str();
2312 let calls_engine = TldrDifferentialEngine::with_timeout(300);
2313 let current_calls_json = calls_engine
2314 .run_tldr_flow_command("calls", &["calls"], &ctx.project, language_str)
2315 .ok();
2316
2317 let flow_findings = self.analyze_flow_commands(
2319 &ctx.project,
2320 &ctx.base_ref,
2321 language_str,
2322 current_calls_json.as_ref(),
2323 &mut partial_reasons,
2324 );
2325 all_findings.extend(flow_findings);
2326
2327 let impact_findings = self.analyze_downstream_impact(
2329 &ctx.project,
2330 &ctx.changed_files,
2331 language_str,
2332 current_calls_json.as_ref(),
2333 &mut partial_reasons,
2334 );
2335 all_findings.extend(impact_findings);
2336
2337 let func_impact_findings = self.analyze_function_impact(
2338 &ctx.project,
2339 &ctx.changed_files,
2340 language_str,
2341 current_calls_json.as_ref(),
2342 &mut partial_reasons,
2343 );
2344 all_findings.extend(func_impact_findings);
2345
2346 let duration_ms = start.elapsed().as_millis() as u64;
2347
2348 let status = if partial_reasons.is_empty() {
2349 AnalyzerStatus::Complete
2350 } else {
2351 AnalyzerStatus::Partial {
2352 reason: partial_reasons.join("; "),
2353 }
2354 };
2355
2356 L2AnalyzerOutput {
2357 findings: all_findings,
2358 status,
2359 duration_ms,
2360 functions_analyzed,
2361 functions_skipped,
2362 }
2363 }
2364}
2365
2366#[cfg(test)]
2367mod tests {
2368 use super::*;
2369 use crate::commands::bugbot::l2::context::{FunctionDiff, L2Context};
2370 use std::collections::HashMap;
2371 use std::path::PathBuf;
2372 use tldr_core::Language;
2373
2374 fn empty_context() -> L2Context {
2375 L2Context::new(
2376 PathBuf::from("/tmp/test-project"),
2377 Language::Rust,
2378 vec![],
2379 FunctionDiff {
2380 changed: vec![],
2381 inserted: vec![],
2382 deleted: vec![],
2383 },
2384 HashMap::new(),
2385 HashMap::new(),
2386 HashMap::new(),
2387 )
2388 }
2389
2390 #[test]
2395 fn test_engine_name() {
2396 let engine = TldrDifferentialEngine::new();
2397 assert_eq!(engine.name(), "TldrDifferentialEngine");
2398 }
2399
2400 #[test]
2401 fn test_finding_types() {
2402 let engine = TldrDifferentialEngine::new();
2403 let types = engine.finding_types();
2404 assert_eq!(types.len(), 11);
2405 assert!(types.contains(&"complexity-increase"));
2406 assert!(types.contains(&"cognitive-increase"));
2407 assert!(types.contains(&"contract-removed"));
2408 assert!(types.contains(&"smell-introduced"));
2409 assert!(types.contains(&"call-graph-change"));
2410 assert!(types.contains(&"dependency-change"));
2411 assert!(types.contains(&"coupling-increase"));
2412 assert!(types.contains(&"cohesion-decrease"));
2413 assert!(types.contains(&"dead-code-introduced"));
2414 assert!(types.contains(&"downstream-impact"));
2415 assert!(types.contains(&"breaking-change-risk"));
2416 }
2417
2418 #[test]
2419 fn test_default() {
2420 let engine = TldrDifferentialEngine::default();
2421 assert_eq!(engine.name(), "TldrDifferentialEngine");
2422 assert_eq!(engine.timeout_secs, 30);
2423 }
2424
2425 #[test]
2426 fn test_with_timeout() {
2427 let engine = TldrDifferentialEngine::with_timeout(60);
2428 assert_eq!(engine.timeout_secs, 60);
2429 }
2430
2431 #[test]
2432 fn test_languages_empty() {
2433 let engine = TldrDifferentialEngine::new();
2434 assert!(
2435 engine.languages().is_empty(),
2436 "TldrDifferentialEngine is language-agnostic"
2437 );
2438 }
2439
2440 #[test]
2445 fn test_empty_context() {
2446 let engine = TldrDifferentialEngine::new();
2447 let ctx = empty_context();
2448 let output = engine.analyze(&ctx);
2449
2450 assert!(
2451 output.findings.is_empty(),
2452 "Empty context should produce no findings"
2453 );
2454 assert_eq!(output.functions_analyzed, 0);
2455 assert_eq!(output.functions_skipped, 0);
2456 assert!(output.duration_ms < 5000, "Should complete quickly");
2457 }
2458
2459 #[test]
2460 fn test_empty_context_status() {
2461 let engine = TldrDifferentialEngine::new();
2462 let ctx = empty_context();
2463 let output = engine.analyze(&ctx);
2464
2465 match &output.status {
2471 AnalyzerStatus::Complete => {} AnalyzerStatus::Partial { .. } => {} other => panic!("Unexpected status: {:?}", other),
2474 }
2475 }
2476
2477 #[test]
2482 fn test_run_tldr_command_not_found() {
2483 let engine = TldrDifferentialEngine::new();
2486 let result = engine.run_tldr_command(&["complexity"], Path::new("/dev/null"));
2487
2488 match result {
2492 Ok(_) => {} Err(e) => {
2494 assert!(
2495 !e.is_empty(),
2496 "Error message should not be empty"
2497 );
2498 }
2499 }
2500 }
2501
2502 #[test]
2507 fn test_as_trait_object() {
2508 let engine: Box<dyn L2Engine> = Box::new(TldrDifferentialEngine::new());
2509 assert_eq!(engine.name(), "TldrDifferentialEngine");
2510 assert_eq!(engine.finding_types().len(), 11);
2511 assert!(engine.languages().is_empty());
2512 }
2513
2514 #[test]
2519 fn test_finding_id_deterministic() {
2520 let id1 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2521 let id2 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2522 assert_eq!(id1, id2);
2523 }
2524
2525 #[test]
2526 fn test_finding_id_differs_for_different_inputs() {
2527 let id1 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2528 let id2 = compute_finding_id("complexity-increase", Path::new("a.py"), "bar", 10);
2529 assert_ne!(id1, id2);
2530 }
2531
2532 #[test]
2537 fn test_diff_numeric_metrics_increase_detected() {
2538 let engine = TldrDifferentialEngine::new();
2539
2540 let baseline = serde_json::json!({
2541 "functions": [
2542 { "name": "process", "cyclomatic": 2, "line": 1 }
2543 ]
2544 });
2545 let current = serde_json::json!({
2546 "functions": [
2547 { "name": "process", "cyclomatic": 10, "line": 1 }
2548 ]
2549 });
2550
2551 let findings = engine.diff_numeric_metrics(
2552 "complexity-increase",
2553 "cyclomatic",
2554 Path::new("src/lib.py"),
2555 &baseline,
2556 ¤t,
2557 );
2558
2559 assert!(!findings.is_empty(), "Should detect cyclomatic increase");
2560 assert_eq!(findings[0].finding_type, "complexity-increase");
2561 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
2562 assert!(findings[0].finding_id.is_some());
2563
2564 assert_eq!(findings[0].severity, "high");
2566
2567 let evidence = &findings[0].evidence;
2568 assert_eq!(evidence["old_value"], 2.0);
2569 assert_eq!(evidence["new_value"], 10.0);
2570 assert_eq!(evidence["delta"], 8.0);
2571 }
2572
2573 #[test]
2574 fn test_diff_numeric_metrics_decrease_not_flagged() {
2575 let engine = TldrDifferentialEngine::new();
2576
2577 let baseline = serde_json::json!({
2578 "functions": [
2579 { "name": "process", "cyclomatic": 10, "line": 1 }
2580 ]
2581 });
2582 let current = serde_json::json!({
2583 "functions": [
2584 { "name": "process", "cyclomatic": 2, "line": 1 }
2585 ]
2586 });
2587
2588 let findings = engine.diff_numeric_metrics(
2589 "complexity-increase",
2590 "cyclomatic",
2591 Path::new("src/lib.py"),
2592 &baseline,
2593 ¤t,
2594 );
2595
2596 assert!(
2597 findings.is_empty(),
2598 "Decrease should not produce a finding"
2599 );
2600 }
2601
2602 #[test]
2603 fn test_diff_numeric_metrics_new_function_info() {
2604 let engine = TldrDifferentialEngine::new();
2605
2606 let baseline = serde_json::json!({
2607 "functions": []
2608 });
2609 let current = serde_json::json!({
2610 "functions": [
2611 { "name": "new_func", "cyclomatic": 15, "line": 5 }
2612 ]
2613 });
2614
2615 let findings = engine.diff_numeric_metrics(
2616 "complexity-increase",
2617 "cyclomatic",
2618 Path::new("src/lib.py"),
2619 &baseline,
2620 ¤t,
2621 );
2622
2623 assert!(!findings.is_empty(), "New function with high metric should be reported");
2624 assert_eq!(findings[0].severity, "info");
2625 assert!(findings[0].evidence["new_function"].as_bool().unwrap_or(false));
2626 }
2627
2628 #[test]
2629 fn test_diff_numeric_metrics_no_change() {
2630 let engine = TldrDifferentialEngine::new();
2631
2632 let baseline = serde_json::json!({
2633 "functions": [
2634 { "name": "process", "cyclomatic": 5, "line": 1 }
2635 ]
2636 });
2637 let current = serde_json::json!({
2638 "functions": [
2639 { "name": "process", "cyclomatic": 5, "line": 1 }
2640 ]
2641 });
2642
2643 let findings = engine.diff_numeric_metrics(
2644 "complexity-increase",
2645 "cyclomatic",
2646 Path::new("src/lib.py"),
2647 &baseline,
2648 ¤t,
2649 );
2650
2651 assert!(
2652 findings.is_empty(),
2653 "No change should produce no findings"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_diff_contracts_removed() {
2659 let engine = TldrDifferentialEngine::new();
2660
2661 let baseline = serde_json::json!({
2662 "functions": [
2663 {
2664 "name": "validate",
2665 "preconditions": [{"expr": "x > 0"}],
2666 "postconditions": [{"expr": "result >= 0"}]
2667 }
2668 ]
2669 });
2670 let current = serde_json::json!({
2671 "functions": [
2672 {
2673 "name": "validate",
2674 "preconditions": [],
2675 "postconditions": []
2676 }
2677 ]
2678 });
2679
2680 let findings = engine.diff_contracts(
2681 Path::new("src/lib.py"),
2682 &baseline,
2683 ¤t,
2684 &["validate".to_string()],
2685 );
2686
2687 assert!(!findings.is_empty(), "Should detect removed contracts");
2688 assert_eq!(findings[0].finding_type, "contract-removed");
2689 assert_eq!(findings[0].severity, "medium");
2690 assert_eq!(findings[0].evidence["removed"], 2);
2691 }
2692
2693 #[test]
2694 fn test_diff_contracts_function_deleted() {
2695 let engine = TldrDifferentialEngine::new();
2696
2697 let baseline = serde_json::json!({
2698 "functions": [
2699 {
2700 "name": "validate",
2701 "preconditions": [{"expr": "x > 0"}],
2702 "postconditions": []
2703 }
2704 ]
2705 });
2706 let current = serde_json::json!({
2707 "functions": []
2708 });
2709
2710 let findings = engine.diff_contracts(
2712 Path::new("src/lib.py"),
2713 &baseline,
2714 ¤t,
2715 &[],
2716 );
2717
2718 assert!(!findings.is_empty(), "Should detect deleted function with contracts");
2719 assert_eq!(findings[0].severity, "high");
2720 assert!(findings[0].evidence["function_deleted"].as_bool().unwrap_or(false));
2721 }
2722
2723 #[test]
2724 fn test_diff_contracts_extraction_failure_not_treated_as_deletion() {
2725 let engine = TldrDifferentialEngine::new();
2726
2727 let baseline = serde_json::json!({
2728 "functions": [
2729 {
2730 "name": "validate",
2731 "preconditions": [{"expr": "x > 0"}],
2732 "postconditions": []
2733 }
2734 ]
2735 });
2736 let current = serde_json::json!({
2739 "functions": []
2740 });
2741
2742 let findings = engine.diff_contracts(
2744 Path::new("src/lib.rs"),
2745 &baseline,
2746 ¤t,
2747 &["validate".to_string()],
2748 );
2749
2750 assert!(findings.is_empty(), "Should NOT emit contract-removed when function exists but extraction failed");
2751 }
2752
2753 #[test]
2754 fn test_diff_smells_introduced() {
2755 let engine = TldrDifferentialEngine::new();
2756
2757 let baseline = serde_json::json!({
2758 "smells": [
2759 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2760 ]
2761 });
2762 let current = serde_json::json!({
2763 "smells": [
2764 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 },
2765 { "smell_type": "god_class", "name": "Handler", "line": 20, "reason": "too many methods", "severity": 2 }
2766 ]
2767 });
2768
2769 let findings = engine.diff_smells(
2770 Path::new("src/lib.py"),
2771 &baseline,
2772 ¤t,
2773 );
2774
2775 assert!(!findings.is_empty(), "Should detect introduced smell");
2776 assert_eq!(findings[0].finding_type, "smell-introduced");
2777 assert_eq!(findings[0].severity, "medium"); assert_eq!(findings[0].evidence["introduced"], 1);
2779 assert_eq!(findings[0].evidence["smell_type"], "god_class");
2781 assert!(findings[0].message.contains("god_class"));
2782 }
2783
2784 #[test]
2785 fn test_diff_smells_no_regression() {
2786 let engine = TldrDifferentialEngine::new();
2787
2788 let baseline = serde_json::json!({
2789 "smells": [
2790 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2791 ]
2792 });
2793 let current = serde_json::json!({
2794 "smells": [
2795 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2796 ]
2797 });
2798
2799 let findings = engine.diff_smells(
2800 Path::new("src/lib.py"),
2801 &baseline,
2802 ¤t,
2803 );
2804
2805 assert!(findings.is_empty(), "Same smells should produce no findings");
2806 }
2807
2808 #[test]
2809 fn test_diff_smells_new_file_baseline_empty() {
2810 let engine = TldrDifferentialEngine::new();
2811
2812 let baseline = serde_json::json!({ "smells": [] });
2815 let current = serde_json::json!({
2816 "smells": [
2817 { "smell_type": "god_class", "name": "BigEngine", "line": 10, "reason": "too big", "severity": 2 },
2818 { "smell_type": "long_method", "name": "run", "line": 50, "reason": "too long", "severity": 1 },
2819 { "smell_type": "long_method", "name": "analyze", "line": 200, "reason": "too long", "severity": 1 }
2820 ]
2821 });
2822
2823 let findings = engine.diff_smells(
2824 Path::new("src/new_module.rs"),
2825 &baseline,
2826 ¤t,
2827 );
2828
2829 assert!(findings.is_empty(), "New file (empty baseline) should not trigger smell-introduced");
2830 }
2831
2832 #[test]
2833 fn test_diff_smells_real_tldr_schema() {
2834 let engine = TldrDifferentialEngine::new();
2836
2837 let baseline = serde_json::json!({
2838 "smells": [
2839 {
2840 "smell_type": "long_method",
2841 "file": "src/engine.rs",
2842 "name": "analyze",
2843 "line": 100,
2844 "reason": "Method has 52 lines of code (threshold: 50)",
2845 "severity": 1
2846 }
2847 ],
2848 "files_scanned": 1,
2849 "by_file": {},
2850 "summary": { "total": 1 }
2851 });
2852 let current = serde_json::json!({
2853 "smells": [
2854 {
2855 "smell_type": "long_method",
2856 "file": "src/engine.rs",
2857 "name": "analyze",
2858 "line": 100,
2859 "reason": "Method has 80 lines of code (threshold: 50)",
2860 "severity": 2
2861 },
2862 {
2863 "smell_type": "feature_envy",
2864 "file": "src/engine.rs",
2865 "name": "diff_metrics",
2866 "line": 200,
2867 "reason": "Method accesses 5 foreign fields",
2868 "severity": 1
2869 },
2870 {
2871 "smell_type": "data_clump",
2872 "file": "src/engine.rs",
2873 "name": "analyze_batch",
2874 "line": 300,
2875 "reason": "3 parameters always appear together",
2876 "severity": 1
2877 }
2878 ],
2879 "files_scanned": 1,
2880 "by_file": {},
2881 "summary": { "total": 3 }
2882 });
2883
2884 let findings = engine.diff_smells(
2885 Path::new("src/engine.rs"),
2886 &baseline,
2887 ¤t,
2888 );
2889
2890 assert_eq!(findings.len(), 2, "Should detect 2 introduced smells");
2891 let types: Vec<&str> = findings.iter().map(|f| f.evidence["smell_type"].as_str().unwrap()).collect();
2893 assert!(types.contains(&"feature_envy"), "Should extract feature_envy type");
2894 assert!(types.contains(&"data_clump"), "Should extract data_clump type");
2895 assert!(findings.iter().all(|f| f.severity == "medium"), "Structural smells should be medium severity");
2897 assert!(!types.contains(&"unknown"), "No smell should have type 'unknown'");
2899 }
2900
2901 #[test]
2902 fn test_diff_smells_suppressed_types_filtered() {
2903 let engine = TldrDifferentialEngine::new();
2904
2905 let baseline = serde_json::json!({
2906 "smells": [
2907 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2908 ]
2909 });
2910 let current = serde_json::json!({
2912 "smells": [
2913 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 },
2914 { "smell_type": "message_chain", "name": "chain", "line": 50, "reason": "chain length 4", "severity": 1 },
2915 { "smell_type": "long_parameter_list", "name": "many_params", "line": 80, "reason": "6 params", "severity": 1 }
2916 ]
2917 });
2918
2919 let findings = engine.diff_smells(
2920 Path::new("src/lib.rs"),
2921 &baseline,
2922 ¤t,
2923 );
2924
2925 assert!(findings.is_empty(), "Suppressed smell types should produce no findings");
2926 }
2927
2928 #[test]
2929 fn test_extract_function_entries_from_functions_key() {
2930 let json = serde_json::json!({
2931 "functions": [
2932 { "name": "foo", "value": 1 },
2933 { "name": "bar", "value": 2 }
2934 ]
2935 });
2936
2937 let entries = TldrDifferentialEngine::extract_function_entries(&json);
2938 assert_eq!(entries.len(), 2);
2939 assert_eq!(entries[0].0, "foo");
2940 assert_eq!(entries[1].0, "bar");
2941 }
2942
2943 #[test]
2944 fn test_extract_function_entries_from_root_array() {
2945 let json = serde_json::json!([
2946 { "name": "foo", "value": 1 },
2947 { "name": "bar", "value": 2 }
2948 ]);
2949
2950 let entries = TldrDifferentialEngine::extract_function_entries(&json);
2951 assert_eq!(entries.len(), 2);
2952 }
2953
2954 #[test]
2955 fn test_extract_function_entries_empty() {
2956 let json = serde_json::json!({ "other": 42 });
2957 let entries = TldrDifferentialEngine::extract_function_entries(&json);
2958 assert!(entries.is_empty());
2959 }
2960
2961 #[test]
2962 fn test_count_dead_code_entries() {
2963 let json = serde_json::json!({
2964 "dead_code": [
2965 { "name": "unused_fn", "file": "src/lib.rs" },
2966 { "name": "old_helper", "file": "src/utils.rs" }
2967 ]
2968 });
2969 assert_eq!(TldrDifferentialEngine::count_dead_code_entries(&json), 2);
2970 }
2971
2972 #[test]
2973 fn test_count_dead_code_entries_empty() {
2974 let json = serde_json::json!({ "dead_code": [] });
2975 assert_eq!(TldrDifferentialEngine::count_dead_code_entries(&json), 0);
2976 }
2977
2978 #[test]
2979 fn test_severity_thresholds() {
2980 let engine = TldrDifferentialEngine::new();
2981
2982 let high = serde_json::json!({ "functions": [{ "name": "f", "metric": 2.0, "line": 1 }] });
2984 let high_curr = serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
2985 let findings = engine.diff_numeric_metrics("test-increase", "metric", Path::new("a.py"), &high, &high_curr);
2986 assert_eq!(findings[0].severity, "high");
2987
2988 let med = serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
2990 let med_curr = serde_json::json!({ "functions": [{ "name": "f", "metric": 14.0, "line": 1 }] });
2991 let findings = engine.diff_numeric_metrics("test-increase", "metric", Path::new("a.py"), &med, &med_curr);
2992 assert_eq!(findings[0].severity, "medium");
2993
2994 let low = serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
2996 let low_curr = serde_json::json!({ "functions": [{ "name": "f", "metric": 11.0, "line": 1 }] });
2997 let findings = engine.diff_numeric_metrics("test-increase", "metric", Path::new("a.py"), &low, &low_curr);
2998 assert_eq!(findings[0].severity, "low");
2999 }
3000
3001 #[test]
3002 fn test_cognitive_delta_threshold_filters_trivial() {
3003 let engine = TldrDifferentialEngine::new();
3004
3005 let baseline = serde_json::json!({ "functions": [{ "name": "f", "cognitive": 2.0, "line": 1 }] });
3007 let current = serde_json::json!({ "functions": [{ "name": "f", "cognitive": 4.0, "line": 1 }] });
3008 let findings = engine.diff_numeric_metrics("cognitive-increase", "cognitive", Path::new("a.rs"), &baseline, ¤t);
3009 assert!(findings.is_empty(), "Cognitive delta of 2 should be suppressed (threshold 3)");
3010
3011 let baseline = serde_json::json!({ "functions": [{ "name": "g", "cognitive": 5.0, "line": 1 }] });
3013 let current = serde_json::json!({ "functions": [{ "name": "g", "cognitive": 8.0, "line": 1 }] });
3014 let findings = engine.diff_numeric_metrics("cognitive-increase", "cognitive", Path::new("a.rs"), &baseline, ¤t);
3015 assert_eq!(findings.len(), 1, "Cognitive delta of 3 should be reported");
3016
3017 let baseline = serde_json::json!({ "functions": [{ "name": "h", "cyclomatic": 3.0, "line": 1 }] });
3019 let current = serde_json::json!({ "functions": [{ "name": "h", "cyclomatic": 4.0, "line": 1 }] });
3020 let findings = engine.diff_numeric_metrics("complexity-increase", "cyclomatic", Path::new("a.rs"), &baseline, ¤t);
3021 assert!(findings.is_empty(), "Complexity delta of 1 should be suppressed (threshold 2)");
3022
3023 let baseline = serde_json::json!({ "functions": [{ "name": "j", "cyclomatic": 3.0, "line": 1 }] });
3025 let current = serde_json::json!({ "functions": [{ "name": "j", "cyclomatic": 5.0, "line": 1 }] });
3026 let findings = engine.diff_numeric_metrics("complexity-increase", "cyclomatic", Path::new("a.rs"), &baseline, ¤t);
3027 assert_eq!(findings.len(), 1, "Complexity delta of 2 should be reported");
3028 }
3029
3030 #[test]
3035 fn test_complexity_diff_real_tldr() {
3036 if Command::new("tldr").arg("--version").output().is_err() {
3038 eprintln!("Skipping test_complexity_diff_real_tldr: tldr not on PATH");
3039 return;
3040 }
3041
3042 let engine = TldrDifferentialEngine::with_timeout(10);
3043
3044 let tmp_dir = TempDir::new().expect("create tmpdir");
3046 let baseline_file = tmp_dir.path().join("baseline.py");
3047 let current_file = tmp_dir.path().join("current.py");
3048
3049 std::fs::write(
3050 &baseline_file,
3051 "def process(x):\n return x + 1\n",
3052 ).expect("write baseline");
3053
3054 std::fs::write(
3055 ¤t_file,
3056 "def process(x):\n if x > 10:\n if x > 20:\n return x * 3\n return x * 2\n return x\n",
3057 ).expect("write current");
3058
3059 let baseline_result = engine.run_tldr_command(&["complexity"], &baseline_file);
3061 let current_result = engine.run_tldr_command(&["complexity"], ¤t_file);
3062
3063 match (baseline_result, current_result) {
3065 (Ok(baseline_json), Ok(current_json)) => {
3066 assert!(baseline_json.is_object() || baseline_json.is_array());
3068 assert!(current_json.is_object() || current_json.is_array());
3069 }
3070 (Err(e), _) => {
3071 eprintln!("Baseline complexity failed (acceptable): {}", e);
3073 }
3074 (_, Err(e)) => {
3075 eprintln!("Current complexity failed (acceptable): {}", e);
3076 }
3077 }
3078 }
3079
3080 #[test]
3085 fn test_tldr_commands_count() {
3086 assert_eq!(TLDR_COMMANDS.len(), 9);
3087 }
3088
3089 #[test]
3090 fn test_tldr_commands_local_count() {
3091 let local_count = TLDR_COMMANDS.iter().filter(|c| c.category == TldrCategory::Local).count();
3092 assert_eq!(local_count, 4);
3093 }
3094
3095 #[test]
3096 fn test_tldr_commands_flow_count() {
3097 let flow_count = TLDR_COMMANDS.iter().filter(|c| c.category == TldrCategory::Flow).count();
3098 assert_eq!(flow_count, 5);
3099 }
3100
3101 #[test]
3102 fn test_finding_types_match_commands() {
3103 assert_eq!(FINDING_TYPES.len(), TLDR_COMMANDS.len() + 2);
3107 assert!(FINDING_TYPES.contains(&"downstream-impact"));
3109 assert!(FINDING_TYPES.contains(&"breaking-change-risk"));
3110 }
3111
3112 #[test]
3117 fn test_diff_calls_new_edges_detected() {
3118 let engine = TldrDifferentialEngine::new();
3119 let baseline = serde_json::json!({
3120 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3121 "edge_count": 1
3122 });
3123 let current = serde_json::json!({
3124 "edges": [
3125 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3126 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"}
3127 ],
3128 "edge_count": 2
3129 });
3130 let findings = engine.diff_calls_json(&baseline, ¤t);
3131 assert!(!findings.is_empty(), "Should detect new call graph edge");
3132 assert_eq!(findings[0].finding_type, "call-graph-change");
3133 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
3134 assert!(findings[0].finding_id.is_some());
3135 }
3136
3137 #[test]
3138 fn test_diff_calls_no_change() {
3139 let engine = TldrDifferentialEngine::new();
3140 let json = serde_json::json!({
3141 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3142 "edge_count": 1
3143 });
3144 let findings = engine.diff_calls_json(&json, &json);
3145 assert!(findings.is_empty(), "No change should produce no findings");
3146 }
3147
3148 #[test]
3149 fn test_diff_calls_removed_edge_reported() {
3150 let engine = TldrDifferentialEngine::new();
3151 let baseline = serde_json::json!({
3152 "edges": [
3153 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3154 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"}
3155 ],
3156 "edge_count": 2
3157 });
3158 let current = serde_json::json!({
3159 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3160 "edge_count": 1
3161 });
3162 let findings = engine.diff_calls_json(&baseline, ¤t);
3163 assert!(!findings.is_empty(), "Should detect removed call graph edge");
3164 assert_eq!(findings[0].finding_type, "call-graph-change");
3165 }
3166
3167 #[test]
3168 fn test_diff_calls_many_new_edges_medium_severity() {
3169 let engine = TldrDifferentialEngine::new();
3170 let baseline = serde_json::json!({
3171 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3172 "edge_count": 1
3173 });
3174 let current = serde_json::json!({
3176 "edges": [
3177 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3178 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"},
3179 {"src_file": "a.rs", "src_func": "foo", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct"},
3180 {"src_file": "a.rs", "src_func": "foo", "dst_file": "e.rs", "dst_func": "quux", "call_type": "direct"},
3181 {"src_file": "b.rs", "src_func": "bar", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"},
3182 {"src_file": "b.rs", "src_func": "bar", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct"},
3183 {"src_file": "b.rs", "src_func": "bar", "dst_file": "e.rs", "dst_func": "quux", "call_type": "direct"}
3184 ],
3185 "edge_count": 7
3186 });
3187 let findings = engine.diff_calls_json(&baseline, ¤t);
3188 assert!(!findings.is_empty());
3189 let has_medium = findings.iter().any(|f| f.severity == "medium");
3191 assert!(has_medium, "Should produce a medium-severity summary finding for >5 new edges");
3192 }
3193
3194 #[test]
3199 fn test_diff_deps_new_circular_dep_high_severity() {
3200 let engine = TldrDifferentialEngine::new();
3201 let baseline = serde_json::json!({
3202 "internal_dependencies": {"a.rs": ["b.rs"]},
3203 "circular_dependencies": [],
3204 "stats": {"total_internal_deps": 1}
3205 });
3206 let current = serde_json::json!({
3207 "internal_dependencies": {"a.rs": ["b.rs"], "b.rs": ["a.rs"]},
3208 "circular_dependencies": [{"path": ["a.rs", "b.rs", "a.rs"], "len": 3}],
3209 "stats": {"total_internal_deps": 2}
3210 });
3211 let findings = engine.diff_deps_json(&baseline, ¤t);
3212 assert!(!findings.is_empty(), "Should detect new circular dependency");
3213 assert_eq!(findings[0].finding_type, "dependency-change");
3214 assert_eq!(findings[0].severity, "high");
3215 }
3216
3217 #[test]
3218 fn test_diff_deps_no_change() {
3219 let engine = TldrDifferentialEngine::new();
3220 let json = serde_json::json!({
3221 "internal_dependencies": {"a.rs": ["b.rs"]},
3222 "circular_dependencies": [],
3223 "stats": {"total_internal_deps": 1}
3224 });
3225 let findings = engine.diff_deps_json(&json, &json);
3226 assert!(findings.is_empty(), "No change should produce no findings");
3227 }
3228
3229 #[test]
3230 fn test_diff_deps_removed_circular_not_flagged() {
3231 let engine = TldrDifferentialEngine::new();
3232 let baseline = serde_json::json!({
3233 "internal_dependencies": {"a.rs": ["b.rs"], "b.rs": ["a.rs"]},
3234 "circular_dependencies": [{"path": ["a.rs", "b.rs", "a.rs"], "len": 3}],
3235 "stats": {"total_internal_deps": 2}
3236 });
3237 let current = serde_json::json!({
3238 "internal_dependencies": {"a.rs": ["b.rs"]},
3239 "circular_dependencies": [],
3240 "stats": {"total_internal_deps": 1}
3241 });
3242 let findings = engine.diff_deps_json(&baseline, ¤t);
3243 let has_high = findings.iter().any(|f| f.severity == "high");
3245 assert!(!has_high, "Removing circular dependency should not produce high severity finding");
3246 }
3247
3248 #[test]
3249 fn test_diff_deps_internal_deps_dict_count() {
3250 let engine = TldrDifferentialEngine::new();
3252 let baseline = serde_json::json!({
3253 "internal_dependencies": {"a.rs": ["b.rs"]},
3254 "circular_dependencies": [],
3255 "stats": {"total_internal_deps": 1}
3256 });
3257 let current = serde_json::json!({
3258 "internal_dependencies": {"a.rs": ["b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs"]},
3259 "circular_dependencies": [],
3260 "stats": {"total_internal_deps": 7}
3261 });
3262 let findings = engine.diff_deps_json(&baseline, ¤t);
3263 assert!(!findings.is_empty(), "Should detect dependency count increase of 6 (>5 threshold)");
3264 assert_eq!(findings[0].finding_type, "dependency-change");
3265 assert_eq!(findings[0].severity, "medium");
3266 }
3267
3268 #[test]
3269 fn test_diff_deps_fallback_to_dict_counting_without_stats() {
3270 let engine = TldrDifferentialEngine::new();
3272 let baseline = serde_json::json!({
3273 "internal_dependencies": {"a.rs": ["b.rs"]},
3274 "circular_dependencies": []
3275 });
3276 let current = serde_json::json!({
3277 "internal_dependencies": {"a.rs": ["b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs"]},
3278 "circular_dependencies": []
3279 });
3280 let findings = engine.diff_deps_json(&baseline, ¤t);
3281 assert!(!findings.is_empty(), "Should detect dependency count increase even without stats field");
3282 }
3283
3284 #[test]
3289 fn test_diff_coupling_instability_increase_detected() {
3290 let engine = TldrDifferentialEngine::new();
3291 let baseline = serde_json::json!({
3292 "martin_metrics": [
3293 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3294 ],
3295 "pairwise_coupling": []
3296 });
3297 let current = serde_json::json!({
3298 "martin_metrics": [
3299 {"module": "core", "ca": 5, "ce": 8, "instability": 0.62, "abstractness": 0.1}
3300 ],
3301 "pairwise_coupling": []
3302 });
3303 let findings = engine.diff_coupling_json(&baseline, ¤t);
3304 assert!(!findings.is_empty(), "Should detect instability increase");
3305 assert_eq!(findings[0].finding_type, "coupling-increase");
3306 }
3307
3308 #[test]
3309 fn test_diff_coupling_no_change() {
3310 let engine = TldrDifferentialEngine::new();
3311 let json = serde_json::json!({
3312 "martin_metrics": [
3313 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3314 ],
3315 "pairwise_coupling": []
3316 });
3317 let findings = engine.diff_coupling_json(&json, &json);
3318 assert!(findings.is_empty(), "No change should produce no findings");
3319 }
3320
3321 #[test]
3322 fn test_diff_coupling_improvement_not_flagged() {
3323 let engine = TldrDifferentialEngine::new();
3324 let baseline = serde_json::json!({
3325 "martin_metrics": [
3326 {"module": "core", "ca": 5, "ce": 8, "instability": 0.62, "abstractness": 0.1}
3327 ],
3328 "pairwise_coupling": []
3329 });
3330 let current = serde_json::json!({
3331 "martin_metrics": [
3332 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3333 ],
3334 "pairwise_coupling": []
3335 });
3336 let findings = engine.diff_coupling_json(&baseline, ¤t);
3337 assert!(findings.is_empty(), "Coupling decrease should not produce findings");
3338 }
3339
3340 #[test]
3345 fn test_diff_cohesion_lcom4_increase_detected() {
3346 let engine = TldrDifferentialEngine::new();
3347 let baseline = serde_json::json!({
3348 "classes": [
3349 {"class_name": "Engine", "lcom4": 1, "method_count": 5, "field_count": 3}
3350 ],
3351 "summary": {"total_classes": 1}
3352 });
3353 let current = serde_json::json!({
3354 "classes": [
3355 {"class_name": "Engine", "lcom4": 4, "method_count": 8, "field_count": 3}
3356 ],
3357 "summary": {"total_classes": 1}
3358 });
3359 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3360 assert!(!findings.is_empty(), "Should detect LCOM4 increase");
3361 assert_eq!(findings[0].finding_type, "cohesion-decrease");
3362 }
3363
3364 #[test]
3365 fn test_diff_cohesion_no_change() {
3366 let engine = TldrDifferentialEngine::new();
3367 let json = serde_json::json!({
3368 "classes": [
3369 {"class_name": "Engine", "lcom4": 2, "method_count": 5, "field_count": 3}
3370 ],
3371 "summary": {"total_classes": 1}
3372 });
3373 let findings = engine.diff_cohesion_json(&json, &json);
3374 assert!(findings.is_empty(), "No change should produce no findings");
3375 }
3376
3377 #[test]
3378 fn test_diff_cohesion_improvement_not_flagged() {
3379 let engine = TldrDifferentialEngine::new();
3380 let baseline = serde_json::json!({
3381 "classes": [
3382 {"class_name": "Engine", "lcom4": 5, "method_count": 10, "field_count": 3}
3383 ],
3384 "summary": {"total_classes": 1}
3385 });
3386 let current = serde_json::json!({
3387 "classes": [
3388 {"class_name": "Engine", "lcom4": 1, "method_count": 4, "field_count": 3}
3389 ],
3390 "summary": {"total_classes": 1}
3391 });
3392 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3393 assert!(findings.is_empty(), "LCOM4 decrease is an improvement, should not produce findings");
3394 }
3395
3396 #[test]
3397 fn test_diff_cohesion_new_class_high_lcom4_info() {
3398 let engine = TldrDifferentialEngine::new();
3399 let baseline = serde_json::json!({
3400 "classes": [],
3401 "summary": {"total_classes": 0}
3402 });
3403 let current = serde_json::json!({
3404 "classes": [
3405 {"class_name": "GodObject", "lcom4": 5, "method_count": 12, "field_count": 0, "verdict": "split_candidate"}
3406 ],
3407 "summary": {"total_classes": 1}
3408 });
3409 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3410 assert!(!findings.is_empty(), "New class with high LCOM4 should be flagged");
3411 assert_eq!(findings[0].severity, "info");
3412 }
3413
3414 #[test]
3415 fn test_diff_cohesion_backward_compat_name_field() {
3416 let engine = TldrDifferentialEngine::new();
3418 let baseline = serde_json::json!({
3419 "classes": [{"name": "Legacy", "lcom4": 1}],
3420 "summary": {"total_classes": 1}
3421 });
3422 let current = serde_json::json!({
3423 "classes": [{"name": "Legacy", "lcom4": 4}],
3424 "summary": {"total_classes": 1}
3425 });
3426 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3427 assert!(!findings.is_empty(), "Should still work with 'name' field as fallback");
3428 }
3429
3430 #[test]
3435 fn test_l2context_default_base_ref() {
3436 let ctx = empty_context();
3437 assert_eq!(ctx.base_ref, "HEAD", "Default base_ref should be HEAD");
3438 }
3439
3440 #[test]
3441 fn test_l2context_with_base_ref() {
3442 let ctx = empty_context().with_base_ref(String::from("main"));
3443 assert_eq!(ctx.base_ref, "main");
3444 }
3445
3446 #[test]
3451 fn test_analyze_flow_commands_accepts_base_ref_and_language() {
3452 let engine = TldrDifferentialEngine::new();
3453 let mut partial_reasons = Vec::new();
3454 let _findings = engine.analyze_flow_commands(
3456 Path::new("/tmp/nonexistent-project-for-test"),
3457 "HEAD",
3458 "rust",
3459 None,
3460 &mut partial_reasons,
3461 );
3462 }
3465
3466 #[test]
3471 fn test_run_tldr_flow_command_exists() {
3472 let engine = TldrDifferentialEngine::new();
3474 let result = engine.run_tldr_flow_command(
3476 "calls",
3477 &["calls"],
3478 Path::new("/tmp/nonexistent-project"),
3479 "rust",
3480 );
3481 let _ = result;
3483 }
3484
3485 #[test]
3486 fn test_run_tldr_flow_command_builds_args_with_lang() {
3487 let engine = TldrDifferentialEngine::with_timeout(1);
3493
3494 for lang in &["python", "rust", "typescript", "go", "java"] {
3495 let result = engine.run_tldr_flow_command(
3496 "dead",
3497 &["dead"],
3498 Path::new("/tmp/nonexistent"),
3499 lang,
3500 );
3501 let _ = result;
3503 }
3504 }
3505
3506 #[test]
3507 fn test_run_tldr_flow_command_calls_gets_respect_ignore() {
3508 let engine = TldrDifferentialEngine::with_timeout(1);
3511
3512 let _calls_result = engine.run_tldr_flow_command(
3514 "calls",
3515 &["calls"],
3516 Path::new("/tmp/nonexistent"),
3517 "rust",
3518 );
3519 let _deps_result = engine.run_tldr_flow_command(
3520 "deps",
3521 &["deps"],
3522 Path::new("/tmp/nonexistent"),
3523 "rust",
3524 );
3525 }
3526
3527 #[test]
3532 fn test_flow_engine_timeout_is_300s() {
3533 let engine = TldrDifferentialEngine::with_timeout(10);
3540 let mut partial_reasons = Vec::new();
3541 let _findings = engine.analyze_flow_commands(
3542 Path::new("/tmp/nonexistent-project"),
3543 "HEAD",
3544 "python",
3545 None,
3546 &mut partial_reasons,
3547 );
3548 }
3551
3552 #[test]
3557 fn test_analyze_passes_language_to_flow_commands() {
3558 let engine = TldrDifferentialEngine::new();
3561 let ctx = L2Context::new(
3562 PathBuf::from("/tmp/test-project-lang"),
3563 Language::Python,
3564 vec![],
3565 FunctionDiff {
3566 changed: vec![],
3567 inserted: vec![],
3568 deleted: vec![],
3569 },
3570 HashMap::new(),
3571 HashMap::new(),
3572 HashMap::new(),
3573 );
3574 let output = engine.analyze(&ctx);
3575 match &output.status {
3578 AnalyzerStatus::Complete => {}
3579 AnalyzerStatus::Partial { .. } => {}
3580 other => panic!("Unexpected status: {:?}", other),
3581 }
3582 }
3583
3584 #[test]
3589 fn test_finding_types_includes_impact() {
3590 let engine = TldrDifferentialEngine::new();
3591 let types = engine.finding_types();
3592 assert!(
3593 types.contains(&"downstream-impact"),
3594 "FINDING_TYPES must include downstream-impact"
3595 );
3596 assert!(
3597 types.contains(&"breaking-change-risk"),
3598 "FINDING_TYPES must include breaking-change-risk"
3599 );
3600 }
3601
3602 #[test]
3603 fn test_downstream_impact_severity_high() {
3604 let json = serde_json::json!({
3605 "summary": {
3606 "importer_count": 15,
3607 "direct_caller_count": 3,
3608 "affected_test_count": 2
3609 }
3610 });
3611 let file = PathBuf::from("src/lib.rs");
3612 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3613 assert_eq!(findings.len(), 1);
3614 assert_eq!(findings[0].finding_type, "downstream-impact");
3615 assert_eq!(findings[0].severity, "high");
3616 assert_eq!(findings[0].function, "(file-level)");
3617 assert_eq!(findings[0].file, file);
3618 assert_eq!(
3619 findings[0].confidence.as_deref(),
3620 Some("DETERMINISTIC")
3621 );
3622 assert!(findings[0].finding_id.is_some());
3623
3624 let ev = &findings[0].evidence;
3626 assert_eq!(ev["command"], "whatbreaks");
3627 assert_eq!(ev["importer_count"], 15);
3628 assert_eq!(ev["direct_caller_count"], 3);
3629 assert_eq!(ev["affected_test_count"], 2);
3630 }
3631
3632 #[test]
3633 fn test_downstream_impact_severity_medium() {
3634 let json = serde_json::json!({
3635 "summary": {
3636 "importer_count": 7,
3637 "direct_caller_count": 1,
3638 "affected_test_count": 0
3639 }
3640 });
3641 let file = PathBuf::from("src/core.rs");
3642 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3643 assert_eq!(findings.len(), 1);
3644 assert_eq!(findings[0].severity, "medium");
3645 }
3646
3647 #[test]
3648 fn test_downstream_impact_severity_low() {
3649 let json = serde_json::json!({
3650 "summary": {
3651 "importer_count": 2,
3652 "direct_caller_count": 0,
3653 "affected_test_count": 1
3654 }
3655 });
3656 let file = PathBuf::from("src/utils.rs");
3657 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3658 assert_eq!(findings.len(), 1);
3659 assert_eq!(findings[0].severity, "low");
3660 }
3661
3662 #[test]
3663 fn test_downstream_impact_no_findings_when_no_importers() {
3664 let json = serde_json::json!({
3665 "summary": {
3666 "importer_count": 0,
3667 "direct_caller_count": 0,
3668 "affected_test_count": 0
3669 }
3670 });
3671 let file = PathBuf::from("src/leaf.rs");
3672 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3673 assert!(
3674 findings.is_empty(),
3675 "Zero importers and zero callers should produce no findings"
3676 );
3677 }
3678
3679 #[test]
3680 fn test_downstream_impact_boundary_importer_3() {
3681 let json = serde_json::json!({
3683 "summary": {
3684 "importer_count": 3,
3685 "direct_caller_count": 0,
3686 "affected_test_count": 0
3687 }
3688 });
3689 let file = PathBuf::from("src/boundary.rs");
3690 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3691 assert_eq!(findings.len(), 1);
3692 assert_eq!(findings[0].severity, "low");
3693 }
3694
3695 #[test]
3696 fn test_downstream_impact_boundary_importer_4() {
3697 let json = serde_json::json!({
3699 "summary": {
3700 "importer_count": 4,
3701 "direct_caller_count": 0,
3702 "affected_test_count": 0
3703 }
3704 });
3705 let file = PathBuf::from("src/boundary4.rs");
3706 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3707 assert_eq!(findings.len(), 1);
3708 assert_eq!(findings[0].severity, "medium");
3709 }
3710
3711 #[test]
3712 fn test_downstream_impact_boundary_importer_10() {
3713 let json = serde_json::json!({
3715 "summary": {
3716 "importer_count": 10,
3717 "direct_caller_count": 0,
3718 "affected_test_count": 0
3719 }
3720 });
3721 let file = PathBuf::from("src/boundary10.rs");
3722 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3723 assert_eq!(findings.len(), 1);
3724 assert_eq!(findings[0].severity, "medium");
3725 }
3726
3727 #[test]
3728 fn test_downstream_impact_boundary_importer_11() {
3729 let json = serde_json::json!({
3731 "summary": {
3732 "importer_count": 11,
3733 "direct_caller_count": 0,
3734 "affected_test_count": 0
3735 }
3736 });
3737 let file = PathBuf::from("src/boundary11.rs");
3738 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3739 assert_eq!(findings.len(), 1);
3740 assert_eq!(findings[0].severity, "high");
3741 }
3742
3743 #[test]
3744 fn test_downstream_impact_callers_only() {
3745 let json = serde_json::json!({
3747 "summary": {
3748 "importer_count": 0,
3749 "direct_caller_count": 5,
3750 "affected_test_count": 0
3751 }
3752 });
3753 let file = PathBuf::from("src/callers.rs");
3754 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3755 assert_eq!(findings.len(), 1);
3756 assert_eq!(findings[0].severity, "low");
3757 assert!(findings[0].message.contains("5 direct callers"));
3758 }
3759
3760 #[test]
3761 fn test_downstream_impact_summary_at_top_level() {
3762 let json = serde_json::json!({
3764 "importer_count": 6,
3765 "direct_caller_count": 2,
3766 "affected_test_count": 1
3767 });
3768 let file = PathBuf::from("src/flat.rs");
3769 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3770 assert_eq!(findings.len(), 1);
3771 assert_eq!(findings[0].severity, "medium");
3772 }
3773
3774 #[test]
3779 fn test_function_impact_high_severity() {
3780 let json = serde_json::json!({
3781 "targets": {
3782 "process_data": {
3783 "caller_count": 8,
3784 "callers": [
3785 { "file": "main.rs", "function": "run" },
3786 { "file": "handler.rs", "function": "handle" },
3787 { "file": "api.rs", "function": "endpoint" },
3788 { "file": "worker.rs", "function": "execute" },
3789 { "file": "batch.rs", "function": "process_all" },
3790 { "file": "test.rs", "function": "test_it" },
3791 ]
3792 }
3793 }
3794 });
3795 let findings =
3796 TldrDifferentialEngine::parse_impact_findings("process_data", &json);
3797 assert_eq!(findings.len(), 1);
3798 assert_eq!(findings[0].finding_type, "breaking-change-risk");
3799 assert_eq!(findings[0].severity, "high");
3800 assert_eq!(findings[0].function, "process_data");
3801 assert_eq!(findings[0].file, PathBuf::from("(project)"));
3802 assert_eq!(
3803 findings[0].confidence.as_deref(),
3804 Some("DETERMINISTIC")
3805 );
3806 assert!(findings[0].finding_id.is_some());
3807
3808 let ev = &findings[0].evidence;
3810 assert_eq!(ev["command"], "impact");
3811 assert_eq!(ev["caller_count"], 8);
3812 let preview = ev["callers_preview"].as_array().unwrap();
3814 assert_eq!(preview.len(), 5);
3815 }
3816
3817 #[test]
3818 fn test_function_impact_medium_severity() {
3819 let json = serde_json::json!({
3820 "targets": {
3821 "helper_fn": {
3822 "caller_count": 3,
3823 "callers": [
3824 { "file": "a.rs", "function": "foo" },
3825 { "file": "b.rs", "function": "bar" },
3826 { "file": "c.rs", "function": "baz" },
3827 ]
3828 }
3829 }
3830 });
3831 let findings =
3832 TldrDifferentialEngine::parse_impact_findings("helper_fn", &json);
3833 assert_eq!(findings.len(), 1);
3834 assert_eq!(findings[0].severity, "medium");
3835 }
3836
3837 #[test]
3838 fn test_function_impact_info_severity() {
3839 let json = serde_json::json!({
3840 "targets": {
3841 "rare_fn": {
3842 "caller_count": 1,
3843 "callers": [
3844 { "file": "only.rs", "function": "sole_caller" }
3845 ]
3846 }
3847 }
3848 });
3849 let findings =
3850 TldrDifferentialEngine::parse_impact_findings("rare_fn", &json);
3851 assert_eq!(findings.len(), 1);
3852 assert_eq!(findings[0].severity, "info");
3853 }
3854
3855 #[test]
3856 fn test_function_impact_no_callers() {
3857 let json = serde_json::json!({
3858 "targets": {
3859 "leaf_fn": {
3860 "caller_count": 0,
3861 "callers": []
3862 }
3863 }
3864 });
3865 let findings =
3866 TldrDifferentialEngine::parse_impact_findings("leaf_fn", &json);
3867 assert!(
3868 findings.is_empty(),
3869 "Function with zero callers should produce no findings"
3870 );
3871 }
3872
3873 #[test]
3874 fn test_function_impact_missing_target() {
3875 let json = serde_json::json!({
3877 "targets": {
3878 "other_fn": {
3879 "caller_count": 5,
3880 "callers": []
3881 }
3882 }
3883 });
3884 let findings =
3885 TldrDifferentialEngine::parse_impact_findings("missing_fn", &json);
3886 assert!(
3887 findings.is_empty(),
3888 "Missing target key should produce no findings"
3889 );
3890 }
3891
3892 #[test]
3893 fn test_function_impact_fallback_top_level() {
3894 let json = serde_json::json!({
3896 "caller_count": 4,
3897 "callers": [
3898 { "file": "x.rs", "function": "a" },
3899 { "file": "y.rs", "function": "b" },
3900 { "file": "z.rs", "function": "c" },
3901 { "file": "w.rs", "function": "d" },
3902 ]
3903 });
3904 let findings =
3905 TldrDifferentialEngine::parse_impact_findings("any_fn", &json);
3906 assert_eq!(findings.len(), 1);
3907 assert_eq!(findings[0].severity, "medium");
3908 assert_eq!(findings[0].evidence["caller_count"], 4);
3909 }
3910
3911 #[test]
3912 fn test_function_impact_boundary_caller_2() {
3913 let json = serde_json::json!({
3915 "targets": {
3916 "boundary_fn": {
3917 "caller_count": 2,
3918 "callers": [
3919 { "file": "a.rs", "function": "x" },
3920 { "file": "b.rs", "function": "y" },
3921 ]
3922 }
3923 }
3924 });
3925 let findings =
3926 TldrDifferentialEngine::parse_impact_findings("boundary_fn", &json);
3927 assert_eq!(findings.len(), 1);
3928 assert_eq!(findings[0].severity, "medium");
3929 }
3930
3931 #[test]
3932 fn test_function_impact_boundary_caller_5() {
3933 let json = serde_json::json!({
3935 "targets": {
3936 "five_fn": {
3937 "caller_count": 5,
3938 "callers": []
3939 }
3940 }
3941 });
3942 let findings =
3943 TldrDifferentialEngine::parse_impact_findings("five_fn", &json);
3944 assert_eq!(findings.len(), 1);
3945 assert_eq!(findings[0].severity, "medium");
3946 }
3947
3948 #[test]
3949 fn test_function_impact_boundary_caller_6() {
3950 let json = serde_json::json!({
3952 "targets": {
3953 "six_fn": {
3954 "caller_count": 6,
3955 "callers": []
3956 }
3957 }
3958 });
3959 let findings =
3960 TldrDifferentialEngine::parse_impact_findings("six_fn", &json);
3961 assert_eq!(findings.len(), 1);
3962 assert_eq!(findings[0].severity, "high");
3963 }
3964
3965 #[test]
3966 fn test_downstream_impact_finding_id_deterministic() {
3967 let json = serde_json::json!({
3969 "summary": {
3970 "importer_count": 5,
3971 "direct_caller_count": 2,
3972 "affected_test_count": 1
3973 }
3974 });
3975 let file = PathBuf::from("src/stable.rs");
3976 let findings1 = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3977 let findings2 = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3978 assert_eq!(findings1[0].finding_id, findings2[0].finding_id);
3979 }
3980
3981 #[test]
3982 fn test_function_impact_finding_id_deterministic() {
3983 let json = serde_json::json!({
3984 "targets": {
3985 "stable_fn": {
3986 "caller_count": 3,
3987 "callers": []
3988 }
3989 }
3990 });
3991 let findings1 =
3992 TldrDifferentialEngine::parse_impact_findings("stable_fn", &json);
3993 let findings2 =
3994 TldrDifferentialEngine::parse_impact_findings("stable_fn", &json);
3995 assert_eq!(findings1[0].finding_id, findings2[0].finding_id);
3996 }
3997
3998 #[test]
4003 fn test_build_reverse_caller_map_basic() {
4004 let json = serde_json::json!({
4007 "edges": [
4008 { "src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" },
4009 { "src_file": "c.rs", "src_func": "baz", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" }
4010 ]
4011 });
4012 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4013 assert_eq!(map.len(), 1);
4014 assert_eq!(map["bar"].len(), 2);
4015 assert!(map["bar"].contains(&("a.rs".to_string(), "foo".to_string())));
4016 assert!(map["bar"].contains(&("c.rs".to_string(), "baz".to_string())));
4017 }
4018
4019 #[test]
4020 fn test_build_reverse_caller_map_multiple_targets() {
4021 let json = serde_json::json!({
4023 "edges": [
4024 { "src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" },
4025 { "src_file": "c.rs", "src_func": "baz", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct" }
4026 ]
4027 });
4028 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4029 assert_eq!(map.len(), 2);
4030 assert_eq!(map["bar"].len(), 1);
4031 assert_eq!(map["qux"].len(), 1);
4032 }
4033
4034 #[test]
4035 fn test_build_reverse_caller_map_empty_edges() {
4036 let json = serde_json::json!({ "edges": [] });
4037 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4038 assert!(map.is_empty());
4039 }
4040
4041 #[test]
4042 fn test_build_reverse_caller_map_no_edges_key() {
4043 let json = serde_json::json!({ "nodes": [] });
4044 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4045 assert!(map.is_empty());
4046 }
4047
4048 #[test]
4049 fn test_build_reverse_caller_map_malformed_edges_skipped() {
4050 let json = serde_json::json!({
4052 "edges": [
4053 { "src_file": "a.rs", "src_func": "foo" },
4054 { "src_func": "bar", "dst_func": "baz" },
4055 { "src_file": "valid.rs", "src_func": "caller", "dst_file": "t.rs", "dst_func": "target", "call_type": "direct" }
4056 ]
4057 });
4058 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4059 assert_eq!(map.len(), 1);
4061 assert_eq!(map["target"].len(), 1);
4062 }
4063
4064 #[test]
4069 fn test_parse_impact_from_callgraph_high_severity() {
4070 let callers = vec![
4072 ("main.rs".to_string(), "run".to_string()),
4073 ("handler.rs".to_string(), "handle".to_string()),
4074 ("api.rs".to_string(), "endpoint".to_string()),
4075 ("worker.rs".to_string(), "execute".to_string()),
4076 ("batch.rs".to_string(), "process_all".to_string()),
4077 ("scheduler.rs".to_string(), "schedule".to_string()),
4078 ];
4079 let findings =
4080 TldrDifferentialEngine::parse_impact_findings_from_callgraph("process_data", &callers);
4081
4082 assert_eq!(findings.len(), 1);
4083 assert_eq!(findings[0].finding_type, "breaking-change-risk");
4084 assert_eq!(findings[0].severity, "high");
4085 assert_eq!(findings[0].evidence["caller_count"], 6);
4086 assert_eq!(findings[0].evidence["command"], "calls");
4087 assert!(findings[0].message.contains("process_data"));
4088 assert!(findings[0].message.contains("6 callers"));
4089 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4091 assert_eq!(preview.len(), 5);
4092 }
4093
4094 #[test]
4095 fn test_parse_impact_from_callgraph_medium_severity() {
4096 let callers = vec![
4098 ("a.rs".to_string(), "foo".to_string()),
4099 ("b.rs".to_string(), "bar".to_string()),
4100 ("c.rs".to_string(), "baz".to_string()),
4101 ];
4102 let findings =
4103 TldrDifferentialEngine::parse_impact_findings_from_callgraph("helper", &callers);
4104
4105 assert_eq!(findings.len(), 1);
4106 assert_eq!(findings[0].severity, "medium");
4107 assert_eq!(findings[0].evidence["caller_count"], 3);
4108 }
4109
4110 #[test]
4111 fn test_parse_impact_from_callgraph_info_severity() {
4112 let callers = vec![("main.rs".to_string(), "run".to_string())];
4114 let findings =
4115 TldrDifferentialEngine::parse_impact_findings_from_callgraph("private_fn", &callers);
4116
4117 assert_eq!(findings.len(), 1);
4118 assert_eq!(findings[0].severity, "info");
4119 assert_eq!(findings[0].evidence["caller_count"], 1);
4120 }
4121
4122 #[test]
4123 fn test_parse_impact_from_callgraph_no_callers() {
4124 let callers: Vec<(String, String)> = vec![];
4126 let findings =
4127 TldrDifferentialEngine::parse_impact_findings_from_callgraph("unused_fn", &callers);
4128 assert!(findings.is_empty());
4129 }
4130
4131 #[test]
4132 fn test_parse_impact_from_callgraph_callers_preview_format() {
4133 let callers = vec![
4135 ("main.rs".to_string(), "run".to_string()),
4136 ("handler.rs".to_string(), "handle".to_string()),
4137 ];
4138 let findings =
4139 TldrDifferentialEngine::parse_impact_findings_from_callgraph("target", &callers);
4140
4141 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4142 assert_eq!(preview[0], "main.rs::run");
4143 assert_eq!(preview[1], "handler.rs::handle");
4144 }
4145
4146 #[test]
4147 fn test_parse_impact_from_callgraph_finding_fields() {
4148 let callers = vec![
4150 ("src.rs".to_string(), "caller".to_string()),
4151 ("other.rs".to_string(), "other_caller".to_string()),
4152 ];
4153 let findings =
4154 TldrDifferentialEngine::parse_impact_findings_from_callgraph("my_func", &callers);
4155
4156 assert_eq!(findings[0].finding_type, "breaking-change-risk");
4157 assert_eq!(findings[0].file, PathBuf::from("(project)"));
4158 assert_eq!(findings[0].function, "my_func");
4159 assert_eq!(findings[0].line, 0);
4160 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
4161 assert!(findings[0].finding_id.is_some());
4162 }
4163
4164 #[test]
4165 fn test_parse_impact_from_callgraph_boundary_5_callers() {
4166 let callers: Vec<(String, String)> = (0..5)
4168 .map(|i| (format!("f{}.rs", i), format!("fn{}", i)))
4169 .collect();
4170 let findings =
4171 TldrDifferentialEngine::parse_impact_findings_from_callgraph("boundary_fn", &callers);
4172
4173 assert_eq!(findings[0].severity, "medium");
4174 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4176 assert_eq!(preview.len(), 5);
4177 }
4178
4179 #[test]
4180 fn test_parse_impact_from_callgraph_boundary_2_callers() {
4181 let callers = vec![
4183 ("a.rs".to_string(), "fa".to_string()),
4184 ("b.rs".to_string(), "fb".to_string()),
4185 ];
4186 let findings =
4187 TldrDifferentialEngine::parse_impact_findings_from_callgraph("edge_fn", &callers);
4188 assert_eq!(findings[0].severity, "medium");
4189 }
4190
4191 #[test]
4198 fn test_bugbot_derive_deps_basic() {
4199 let calls = serde_json::json!({
4201 "edges": [
4202 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}
4203 ]
4204 });
4205 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4206 let internal = deps["internal_dependencies"].as_object().unwrap();
4207 assert!(internal.contains_key("a.rs"));
4208 let a_deps = internal["a.rs"].as_array().unwrap();
4209 assert_eq!(a_deps.len(), 1);
4210 assert!(a_deps.iter().any(|v| v.as_str() == Some("b.rs")));
4211 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 1);
4212 }
4213
4214 #[test]
4215 fn test_bugbot_derive_deps_intra_file_excluded() {
4216 let calls = serde_json::json!({
4218 "edges": [
4219 {"src_file": "a.rs", "src_func": "foo", "dst_file": "a.rs", "dst_func": "bar", "call_type": "direct"}
4220 ]
4221 });
4222 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4223 let internal = deps["internal_dependencies"].as_object().unwrap();
4224 assert!(internal.is_empty() || internal.values().all(|v| v.as_array().unwrap().is_empty()));
4225 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4226 }
4227
4228 #[test]
4229 fn test_bugbot_derive_deps_deduplication() {
4230 let calls = serde_json::json!({
4232 "edges": [
4233 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
4234 {"src_file": "a.rs", "src_func": "baz", "dst_file": "b.rs", "dst_func": "qux", "call_type": "direct"}
4235 ]
4236 });
4237 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4238 let a_deps = deps["internal_dependencies"]["a.rs"].as_array().unwrap();
4239 assert_eq!(a_deps.len(), 1);
4240 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 1);
4241 }
4242
4243 #[test]
4244 fn test_bugbot_derive_deps_circular_detection() {
4245 let calls = serde_json::json!({
4247 "edges": [
4248 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "f2", "call_type": "direct"},
4249 {"src_file": "b.rs", "src_func": "f2", "dst_file": "a.rs", "dst_func": "f3", "call_type": "direct"}
4250 ]
4251 });
4252 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4253 let circular = deps["circular_dependencies"].as_array().unwrap();
4254 assert!(!circular.is_empty(), "should detect circular dependency between a.rs and b.rs");
4255 let path = circular[0]["path"].as_array().unwrap();
4257 let path_strs: Vec<&str> = path.iter().map(|v| v.as_str().unwrap()).collect();
4258 assert!(path_strs.contains(&"a.rs"));
4259 assert!(path_strs.contains(&"b.rs"));
4260 }
4261
4262 #[test]
4263 fn test_bugbot_derive_deps_empty_edges() {
4264 let calls = serde_json::json!({ "edges": [] });
4265 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4266 let internal = deps["internal_dependencies"].as_object().unwrap();
4267 assert!(internal.is_empty());
4268 let circular = deps["circular_dependencies"].as_array().unwrap();
4269 assert!(circular.is_empty());
4270 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4271 }
4272
4273 #[test]
4274 fn test_bugbot_derive_deps_no_edges_key() {
4275 let calls = serde_json::json!({ "nodes": ["a.rs:foo"] });
4277 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4278 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4279 }
4280
4281 #[test]
4284 fn test_bugbot_derive_coupling_basic() {
4285 let calls = serde_json::json!({
4287 "edges": [
4288 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"},
4289 {"src_file": "c.rs", "src_func": "f2", "dst_file": "b.rs", "dst_func": "g2", "call_type": "direct"}
4290 ]
4291 });
4292 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4293 let metrics = coupling["martin_metrics"].as_array().unwrap();
4294
4295 let b_metric = metrics.iter().find(|m| m["module"].as_str() == Some("b.rs")).unwrap();
4297 assert_eq!(b_metric["ca"].as_u64().unwrap(), 2);
4298 assert_eq!(b_metric["ce"].as_u64().unwrap(), 0);
4299 assert!((b_metric["instability"].as_f64().unwrap() - 0.0).abs() < 0.01);
4300
4301 let a_metric = metrics.iter().find(|m| m["module"].as_str() == Some("a.rs")).unwrap();
4303 assert_eq!(a_metric["ca"].as_u64().unwrap(), 0);
4304 assert_eq!(a_metric["ce"].as_u64().unwrap(), 1);
4305 assert!((a_metric["instability"].as_f64().unwrap() - 1.0).abs() < 0.01);
4306 }
4307
4308 #[test]
4309 fn test_bugbot_derive_coupling_bidirectional() {
4310 let calls = serde_json::json!({
4312 "edges": [
4313 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"},
4314 {"src_file": "b.rs", "src_func": "g2", "dst_file": "a.rs", "dst_func": "f2", "call_type": "direct"}
4315 ]
4316 });
4317 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4318 let metrics = coupling["martin_metrics"].as_array().unwrap();
4319
4320 for module_name in &["a.rs", "b.rs"] {
4321 let m = metrics.iter().find(|m| m["module"].as_str() == Some(*module_name))
4322 .unwrap_or_else(|| panic!("missing metric for {}", module_name));
4323 assert_eq!(m["ca"].as_u64().unwrap(), 1, "{} Ca should be 1", module_name);
4324 assert_eq!(m["ce"].as_u64().unwrap(), 1, "{} Ce should be 1", module_name);
4325 assert!((m["instability"].as_f64().unwrap() - 0.5).abs() < 0.01,
4326 "{} instability should be 0.5", module_name);
4327 }
4328 }
4329
4330 #[test]
4331 fn test_bugbot_derive_coupling_self_calls_excluded() {
4332 let calls = serde_json::json!({
4334 "edges": [
4335 {"src_file": "a.rs", "src_func": "f1", "dst_file": "a.rs", "dst_func": "f2", "call_type": "direct"}
4336 ]
4337 });
4338 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4339 let metrics = coupling["martin_metrics"].as_array().unwrap();
4340 if !metrics.is_empty() {
4342 let a = metrics.iter().find(|m| m["module"].as_str() == Some("a.rs"));
4343 if let Some(a_metric) = a {
4344 assert_eq!(a_metric["ca"].as_u64().unwrap(), 0);
4345 assert_eq!(a_metric["ce"].as_u64().unwrap(), 0);
4346 }
4347 }
4348 }
4349
4350 #[test]
4351 fn test_bugbot_derive_coupling_empty() {
4352 let calls = serde_json::json!({ "edges": [] });
4353 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4354 let metrics = coupling["martin_metrics"].as_array().unwrap();
4355 assert!(metrics.is_empty());
4356 }
4357
4358 #[test]
4361 fn test_bugbot_derive_downstream_basic() {
4362 let calls = serde_json::json!({
4363 "edges": [
4364 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4365 ]
4366 });
4367 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4368 assert_eq!(results.len(), 1);
4369 let (file, metrics) = &results[0];
4370 assert_eq!(file, "lib.rs");
4371 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 1);
4372 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 1);
4373 }
4374
4375 #[test]
4376 fn test_bugbot_derive_downstream_multiple_importers() {
4377 let calls = serde_json::json!({
4378 "edges": [
4379 {"src_file": "a.rs", "src_func": "f1", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4380 {"src_file": "b.rs", "src_func": "f2", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4381 {"src_file": "c.rs", "src_func": "f3", "dst_file": "lib.rs", "dst_func": "init", "call_type": "direct"}
4382 ]
4383 });
4384 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4385 let (_, metrics) = &results[0];
4386 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 3);
4387 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 3);
4388 }
4389
4390 #[test]
4391 fn test_bugbot_derive_downstream_no_callers() {
4392 let calls = serde_json::json!({
4394 "edges": [
4395 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"}
4396 ]
4397 });
4398 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4399 assert_eq!(results.len(), 1);
4400 let (_, metrics) = &results[0];
4401 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 0);
4402 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 0);
4403 }
4404
4405 #[test]
4406 fn test_bugbot_derive_downstream_test_heuristic() {
4407 let calls = serde_json::json!({
4409 "edges": [
4410 {"src_file": "tests/test_lib.rs", "src_func": "test_process", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4411 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4412 ]
4413 });
4414 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4415 let (_, metrics) = &results[0];
4416 assert!(metrics["affected_test_count"].as_u64().unwrap() >= 1,
4417 "test callers should be detected via path/name heuristic");
4418 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 2);
4419 }
4420
4421 #[test]
4422 fn test_bugbot_derive_downstream_self_calls_excluded() {
4423 let calls = serde_json::json!({
4425 "edges": [
4426 {"src_file": "lib.rs", "src_func": "helper", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4427 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4428 ]
4429 });
4430 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4431 let (_, metrics) = &results[0];
4432 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 1, "self-calls should be excluded");
4433 }
4434
4435 #[test]
4436 fn test_bugbot_derive_downstream_same_importer_multiple_calls() {
4437 let calls = serde_json::json!({
4439 "edges": [
4440 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "init", "call_type": "direct"},
4441 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4442 {"src_file": "main.rs", "src_func": "shutdown", "dst_file": "lib.rs", "dst_func": "cleanup", "call_type": "direct"}
4443 ]
4444 });
4445 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4446 let (_, metrics) = &results[0];
4447 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 1, "3 edges from same file = 1 importer");
4448 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 1);
4449 }
4450
4451 #[test]
4456 fn test_analyze_flow_commands_accepts_cached_calls_json() {
4457 let engine = TldrDifferentialEngine::new();
4460 let mut partial_reasons = Vec::new();
4461 let _findings = engine.analyze_flow_commands(
4462 Path::new("/tmp/nonexistent-project-for-cache-test"),
4463 "HEAD",
4464 "rust",
4465 None, &mut partial_reasons,
4467 );
4468 }
4470
4471 #[test]
4472 fn test_analyze_flow_commands_uses_cached_calls_for_deps() {
4473 let engine = TldrDifferentialEngine::new();
4476 let mut partial_reasons = Vec::new();
4477 let calls_json = serde_json::json!({
4478 "edges": [
4479 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}
4480 ]
4481 });
4482 let _findings = engine.analyze_flow_commands(
4486 Path::new("/tmp/nonexistent-project-for-cache-test"),
4487 "HEAD",
4488 "rust",
4489 Some(&calls_json),
4490 &mut partial_reasons,
4491 );
4492 }
4493
4494 #[test]
4495 fn test_analyze_downstream_impact_accepts_cached_calls_json() {
4496 let engine = TldrDifferentialEngine::new();
4500 let mut partial_reasons = Vec::new();
4501 let calls_json = serde_json::json!({
4502 "edges": [
4503 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4504 {"src_file": "tests/test_lib.rs", "src_func": "test_it", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4505 ]
4506 });
4507
4508 let project = Path::new("/tmp/nonexistent-downstream-test");
4509 let changed_files = vec![project.join("lib.rs")];
4510 let findings = engine.analyze_downstream_impact(
4511 project,
4512 &changed_files,
4513 "rust",
4514 Some(&calls_json),
4515 &mut partial_reasons,
4516 );
4517
4518 assert!(!findings.is_empty(), "cached calls should produce downstream findings");
4520 assert_eq!(findings[0].finding_type, "downstream-impact");
4521 }
4522
4523 #[test]
4524 fn test_analyze_downstream_impact_none_falls_back() {
4525 let engine = TldrDifferentialEngine::new();
4529 let mut partial_reasons = Vec::new();
4530 let project = Path::new("/tmp/nonexistent-downstream-fallback");
4531 let changed_files = vec![project.join("lib.rs")];
4532 let _findings = engine.analyze_downstream_impact(
4533 project,
4534 &changed_files,
4535 "rust",
4536 None,
4537 &mut partial_reasons,
4538 );
4539 }
4541
4542 #[test]
4543 fn test_analyze_function_impact_accepts_cached_calls_json() {
4544 let engine = TldrDifferentialEngine::new();
4547 let mut partial_reasons = Vec::new();
4548 let calls_json = serde_json::json!({
4549 "edges": [
4550 {"src_file": "caller.rs", "src_func": "caller_fn", "dst_file": "lib.rs", "dst_func": "target_fn", "call_type": "direct"}
4551 ]
4552 });
4553 let project = Path::new("/tmp/nonexistent-function-impact-test");
4554 let changed_files = vec![project.join("lib.rs")];
4555 let _findings = engine.analyze_function_impact(
4556 project,
4557 &changed_files,
4558 "rust",
4559 Some(&calls_json),
4560 &mut partial_reasons,
4561 );
4562 }
4564
4565 #[test]
4566 fn test_analyze_function_impact_none_falls_back() {
4567 let engine = TldrDifferentialEngine::new();
4569 let mut partial_reasons = Vec::new();
4570 let project = Path::new("/tmp/nonexistent-function-impact-fallback");
4571 let changed_files = vec![project.join("lib.rs")];
4572 let _findings = engine.analyze_function_impact(
4573 project,
4574 &changed_files,
4575 "rust",
4576 None,
4577 &mut partial_reasons,
4578 );
4579 }
4581
4582 #[test]
4583 fn test_analyze_downstream_with_cached_calls_produces_correct_findings() {
4584 let engine = TldrDifferentialEngine::new();
4587 let mut partial_reasons = Vec::new();
4588 let calls_json = serde_json::json!({
4589 "edges": [
4590 {"src_file": "a.rs", "src_func": "f1", "dst_file": "target.rs", "dst_func": "process", "call_type": "direct"},
4591 {"src_file": "b.rs", "src_func": "f2", "dst_file": "target.rs", "dst_func": "init", "call_type": "direct"},
4592 {"src_file": "c.rs", "src_func": "f3", "dst_file": "target.rs", "dst_func": "run", "call_type": "direct"},
4593 {"src_file": "d.rs", "src_func": "f4", "dst_file": "target.rs", "dst_func": "cleanup", "call_type": "direct"},
4594 ]
4595 });
4596
4597 let project = Path::new("/tmp/nonexistent-downstream-correct");
4598 let changed_files = vec![project.join("target.rs")];
4599 let findings = engine.analyze_downstream_impact(
4600 project,
4601 &changed_files,
4602 "rust",
4603 Some(&calls_json),
4604 &mut partial_reasons,
4605 );
4606
4607 assert_eq!(findings.len(), 1);
4609 assert_eq!(findings[0].severity, "medium");
4610 assert_eq!(findings[0].finding_type, "downstream-impact");
4611 assert_eq!(findings[0].evidence["importer_count"], 4);
4613 }
4614}